diff --git a/Cargo.lock b/Cargo.lock index 99badd1e..dde6a649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4178,6 +4178,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "unicode-normalization", "urlencoding", @@ -5808,6 +5809,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.117" @@ -6377,6 +6384,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index 651cc640..4af63220 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,10 @@ tower-http = { version = "0.6.11", features = ["fs", "compression-gzip", "compre flate2 = "1.1.9" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +# Round-11: moves log formatting+write off the async workers. `lossy(false)` +# so audit lines are never dropped (emitters block only when the 128k-line +# channel is full — backpressure, not loss). See benches/ROUND11.md. +tracing-appender = "0.2" chrono = { version = "0.4.45", features = ["serde"] } # RFC 5545 iCalendar parser + emitter. # @@ -350,6 +354,34 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-11 battery ──────────────────────────────────────────────────────────── + +# Round-11 CPU/alloc micro-pack — download DTO hand-off, Last-Modified stack +# render, status.php/openapi.json memoization, upload-session PROPFIND emit, +# rate-limiter single-op, CSRF/ETag/recent-id micro-allocs, 4xx body, vCard +# emit, search page move, cosine norms, encrypted write in-place, StoragePath +# joined-only materialization. No Postgres. +[[example]] +name = "bench_round11_micro" +path = "examples/bench_round11_micro.rs" +required-features = ["bench"] + +# Round-11 query-shape pack — deferred-upload 3→1 CTE, Calendar/AddressBook/ +# Playlist direct-grant cache, expand_user join!, geo min-cast, recluster +# UNNEST batch (needs the dev Postgres up). +[[example]] +name = "bench_round11_queries" +path = "examples/bench_round11_queries.rs" +required-features = ["bench"] + +# Round-11 log-writer benchmark — sync stdout fmt layer vs tracing-appender +# non_blocking(lossy=false) under 4-worker emit contention, fast + slow +# writer profiles. Run once per arm via BENCH_LOG_ARM. No Postgres. +[[example]] +name = "bench_log_writer" +path = "examples/bench_log_writer.rs" +required-features = ["bench"] + # Round-10 battery ──────────────────────────────────────────────────────────── # Round-10 CPU/alloc micro-pack — auth identity build, basic-auth hit, diff --git a/benches/ROUND11.md b/benches/ROUND11.md new file mode 100644 index 00000000..8826dee3 --- /dev/null +++ b/benches/ROUND11.md @@ -0,0 +1,116 @@ +# Round 11 — StoragePath re-representation, classifier fusion, memoized static bodies, query-shape pack, SPA fine-grained stars + +Benchmark-gated, same rule as ROUND2-10: 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. Three candidates went +through exactly that loop this round (§Rejected): the moka `and_upsert_with` +rate-limiter rewrite measured SLOWER than the two-op shape it was meant to +replace and was redesigned as a lock-free `get`+`insert`; the GET/HEAD +`Last-Modified` stack-render port measured neutral-to-worse (the chrono +String is already the terminal allocation) and was dropped; the first +search-page `drain(range)` model lost to `to_vec` on wall and was reshaped +as `into_iter().skip().take()`. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release +profile; frontend on Node 22 / vitest 4 (jsdom). Reproduce any row with the +command in its section. + +## Summary + +(numbers filled from the final runs below) + +| # | change | key metric | before → after | +|--:|---|---|---| +| 1 | REST download: dead `FileDto` clone → capture mime/size + move | allocs per download | 7 → 0 (dead clone removed) | +| 2 | `StoragePath` → single canonical joined `String` (segments derived lazily; entity `path_string` duplicate field removed) | allocs / wall per 500-row page | TBD | +| 3 | Display classifier fusion (`classify_display`, stack-lowered ext shared by the three trees) | ns / allocs per listing row | TBD | +| 4 | `/status.php` → `OnceLock` | ns / allocs per poll | TBD | +| 5 | `/openapi.json` → `OnceLock` (was: rebuild 171 KiB spec per request) | ns per request | TBD | +| 6 | NC upload-session PROPFIND: `write!` + capacity + stack dates | ns / allocs per 256-chunk PROPFIND | TBD | +| 7 | CSRF header token borrow compare | ns / allocs per state-changing request | TBD | +| 8 | Thumbnail/preview ETag: `as_str` push (Debug-identical bytes) | ns per thumbnail request | TBD | +| 9 | Recent-handler id: stack `encode_lower` | ns / allocs per call | TBD | +| 10 | 4xx body: borrowed serialize + `ErrorKind::as_str` + `not_found` clone kill | ns / allocs per 404 | TBD | +| 11 | vCard emit: `write!` (+ borrowed address fields) | ns / allocs per vCard | TBD | +| 12 | Search page: `into_iter().skip().take()` move | allocs per 50-item page | TBD | +| 13 | Content-hit verify: parse-once pairs | ns per 100-hit page | TBD | +| 14 | Group last-user check: HashSet probe | ns per 500×500 check | TBD | +| 15 | Retry op-label: lazy closure | ns / allocs per blob op | TBD | +| 16 | `encrypt_bytes`: in-place detached (write side now mirrors the in-place read) | ns / allocs per 256 KiB chunk | TBD | +| 17 | Encrypted `collect_stream`: chunk-sized reserve | allocs per 1 MiB read | TBD | +| 18 | Recluster cosine: precomputed norms (bit-identical) | ns per 200-face pass | TBD | +| 19 | `CalendarEventDto`: `into_parts` move (11 KiB `ical_data` copy gone) | ns / allocs per event | TBD | +| 20 | RateLimiter: lock-free `get` + `insert` | ns / allocs per limited request | TBD | +| Q1 | Deferred upload registration: 3 round-trips → 1 CTE insert | ms per uploaded file | TBD | +| Q2 | Calendar/AddressBook/Playlist authz `direct_grant_cache` | ms per DAV check | TBD | +| Q3 | `expand_user`: `tokio::join!` the 2 independent queries | ms per cold expansion | TBD | +| Q4 | Geo clusters: `min(file_id)::text` (cast per cluster, not per row) | ms per viewport | TBD | +| Q5 | Recluster persistence: per-face UPDATEs → one UNNEST batch | ms per 200-face apply | TBD | +| L1 | Log writer: `tracing-appender` non_blocking (lossy=false) | p99 emit µs under contention | TBD | +| S1 | SPA `ResourceList.selectedEntries`: O(N)×2 per toggle → O(k·log k), hosts consume the snippet param | comparisons per toggle | 2N → k | +| S2 | SPA Recent: star reads `favoriteIds` prop, mapper no longer set-dependent | rows re-mapped per star click | N → 0 | +| S3 | SPA admin `timeAgo`: cached `Intl.DateTimeFormat` | constructions per 1000 formats | ≤1 (was 1000) | + +Also shipped without a dedicated row: `already_exists` clone kill (same +shape as `not_found`), CardDAV `getlastmodified` stack render (per-contact +REPORT path, ROUND10-§13 helper + fallback), NC capabilities poll logs +demoted to `debug` (INFO forced a locked-stdout write per client poll), +trash `to_dto` `into_parts` move + interned display fields (trash listing ++ path-resolver rows now share the ROUND9 interning). + +## Rejected / reworked this round (the discipline working) + +- **RateLimiter `entry().and_upsert_with`**: 1 846.5 → 1 862.1 ns and + 8.0 → 9.1 allocs/op — moka's compute-entry machinery costs more than the + two-op shape it replaced. Redesigned as lock-free `get` (borrows the + key, no alloc) + `insert`; identical counter sequence gated. +- **GET/HEAD `Last-Modified` stack-render port**: chrono's `to_rfc2822()` + String IS the terminal allocation the header needs (44.3 ns incl. the + alloc vs 46.5 ns for stack render + the same alloc). Only body-emit + sites (where `write!` lands in an existing buffer) benefit — those were + ported (§6, CardDAV); the header sites were left on chrono. +- **Search page `drain(range).collect()`**: −300 allocs but slower on + wall than `to_vec` in the first model (tail memmove). Reshaped as + `into_iter().skip().take().collect()` — moves the page, drops the rest, + no tail shift. + +## Deferred / flagged (not shipped this round) + +- **NC preview 304 still runs `get_file`** (`preview_handler.rs`): the + object-id → file fetch on the revalidation path is only needed for + existence semantics (a deleted file must 404, not 304). Dropping it is + a behavior decision — same class as the standing CalDAV + authz-before-fetch reorder — flagged for maintainer sign-off. +- **`CachedBlobBackend`'s `Mutex` index** serializes every + cached read; a moka byte-weigher migration (the file-content-cache + pattern) is the natural fix but touches eviction-unlink semantics — + deserves its own round with a concurrency bench. +- **Capture-metadata extraction reads each media file 2-3×** + (`media_metadata_service.rs`: kamadak full read + nom-exif path re-read + + track fallback re-read). Feeding nom-exif from the in-memory buffer + needs its `MediaSource` API verified on the pinned version. +- **`CachedBlobBackend::local_blob_path` sync `stat`** (ROUND10 flag + stands): needs an async port variant. +- **Azure SDK 0.21 stack** drags duplicate dependency trees (h2 0.3+0.4, + two hashbrown generations, base64 0.13) into the binary; an SDK bump is + a dedicated migration, not a perf tweak. +- **`AudioMetadataRepository::list_by_{artist,album,genre}`** are dead + code (never called) with seq-scan `ILIKE` shapes — flag for deletion + rather than indexing. +- **`CachedBlobBackend::put_blob` cache population** silently fails for + S3/Azure whole-file puts (the inner backend deletes the source before + the cache copy runs) — correctness note for maintainers, not perf. +- **CalDAV authz-before-fetch reorder** — ROUND9/10 flag stands. + +## Environment / methodology + +- `cargo run --release --features bench --example bench_round11_micro` + (pure CPU, counting allocator, BEFORE replicas vs shipped code). +- `cargo run --release --features bench --example bench_round11_queries` + (Postgres; seeds + sweeps its own fixtures). +- `BENCH_LOG_ARM=sync|nonblocking [BENCH_LOG_WRITER=slow] cargo run + --release --features bench --example bench_log_writer >/dev/null`. +- `cd frontend && npx vitest run src/lib/components/round11.bench.test.ts`. +- Regression guards from earlier rounds re-run after the StoragePath / + classifier changes: `bench_row_path` (round-4 gates) and `bench_dto_map` + (round-3 gates). diff --git a/examples/bench_dto_map.rs b/examples/bench_dto_map.rs index 20ee7e5d..5ec030e9 100644 --- a/examples/bench_dto_map.rs +++ b/examples/bench_dto_map.rs @@ -139,7 +139,7 @@ mod before { FileDto { id: parts.id, name: parts.name, - path: parts.path_string, + path: parts.storage_path.into_joined(), size: parts.size, mime_type, folder_id: parts.folder_id, diff --git a/examples/bench_log_writer.rs b/examples/bench_log_writer.rs new file mode 100644 index 00000000..91fb66a7 --- /dev/null +++ b/examples/bench_log_writer.rs @@ -0,0 +1,157 @@ +//! Round-11 log-writer benchmark — synchronous fmt layer (stdout under a +//! global lock, on the async workers) vs `tracing_appender::non_blocking` +//! with `lossy(false)` (audit lines must never drop; the emitting thread +//! blocks only if the 128k-line channel fills). +//! +//! Two writer profiles: +//! - fast: stdout redirected to /dev/null (best case for the sync arm) +//! - slow: a writer that burns ~20 µs per line under the same lock, +//! modelling a laggy pipe / journald / TTY consumer +//! +//! The global subscriber can only be installed once per process, so the +//! arm is chosen via env and the harness runs the binary once per arm: +//! +//! BENCH_LOG_ARM=sync cargo run --release --features bench --example bench_log_writer >/dev/null +//! BENCH_LOG_ARM=nonblocking cargo run --release --features bench --example bench_log_writer >/dev/null +//! BENCH_LOG_WRITER=slow BENCH_LOG_ARM=... (slow-writer profile) +//! +//! Measurements print to stderr. Emits 4 workers × 25k events; reports +//! total wall, per-event p50/p99/p999 emit latency, and (for the +//! non-blocking arm) confirms zero dropped lines via a line count gate +//! (lossy(false) + guard flush). + +use std::io::Write; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +static LINES: AtomicU64 = AtomicU64::new(0); + +/// Counts lines then forwards to stdout (which the run command redirects +/// to /dev/null). The `slow` profile burns ~20 µs per write while holding +/// the caller's lock, modelling a slow consumer. +struct CountingWriter { + slow: bool, +} + +impl Write for CountingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + LINES.fetch_add(1, Ordering::Relaxed); + if self.slow { + let t = Instant::now(); + while t.elapsed().as_micros() < 20 { + std::hint::spin_loop(); + } + } + std::io::stdout().write_all(buf)?; + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + std::io::stdout().flush() + } +} + +#[derive(Clone)] +struct MakeCounting { + slow: bool, +} +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for MakeCounting { + type Writer = CountingWriter; + fn make_writer(&'a self) -> Self::Writer { + CountingWriter { slow: self.slow } + } +} + +fn main() { + let arm = std::env::var("BENCH_LOG_ARM").unwrap_or_else(|_| "sync".into()); + let slow = std::env::var("BENCH_LOG_WRITER").as_deref() == Ok("slow"); + let workers = 4usize; + let per_worker = 25_000u64; + + // Same filter shape as main.rs. + let filter = tracing_subscriber::EnvFilter::new("info,http=warn,http::web=error"); + + // Keep the non-blocking guard alive for the whole run. + let _guard: Option = match arm.as_str() { + "nonblocking" => { + let (nb, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() + .lossy(false) + .finish(CountingWriter { slow }); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().with_writer(nb)) + .init(); + Some(guard) + } + _ => { + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().with_writer(MakeCounting { slow })) + .init(); + None + } + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(workers) + .enable_all() + .build() + .unwrap(); + + let (wall, mut lat_us): (f64, Vec) = rt.block_on(async { + let t0 = Instant::now(); + let mut handles = Vec::new(); + for w in 0..workers { + handles.push(tokio::spawn(async move { + let mut lats = Vec::with_capacity(per_worker as usize); + for i in 0..per_worker { + let t = Instant::now(); + tracing::info!(worker = w, seq = i, "bench log line with a few fields"); + lats.push(t.elapsed().as_secs_f64() * 1e6); + if i % 512 == 0 { + tokio::task::yield_now().await; + } + } + lats + })); + } + let mut all = Vec::new(); + for h in handles { + all.extend(h.await.unwrap()); + } + (t0.elapsed().as_secs_f64(), all) + }); + + // Flush (drop guard for non-blocking) before counting lines. + drop(_guard); + std::thread::sleep(std::time::Duration::from_millis(200)); + + lat_us.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let pct = |p: f64| lat_us[((lat_us.len() as f64 * p) as usize).min(lat_us.len() - 1)]; + let total = workers as u64 * per_worker; + let emitted = LINES.load(Ordering::Relaxed); + + eprintln!( + "arm={arm} writer={} events={total} wall={:.3}s ({:.0} ev/s)", + if slow { + "slow(20µs)" + } else { + "fast(/dev/null)" + }, + wall, + total as f64 / wall + ); + eprintln!( + " emit latency µs: p50={:.1} p99={:.1} p999={:.1} max={:.1}", + pct(0.50), + pct(0.99), + pct(0.999), + lat_us[lat_us.len() - 1] + ); + eprintln!( + " gate[no lines dropped]: {}", + if emitted >= total { "OK" } else { "FAILED" } + ); +} diff --git a/examples/bench_round11_micro.rs b/examples/bench_round11_micro.rs new file mode 100644 index 00000000..d7b2c26a --- /dev/null +++ b/examples/bench_round11_micro.rs @@ -0,0 +1,1614 @@ +//! Round-11 CPU/alloc micro-pack — BEFORE replicas vs AFTER shapes. +//! +//! Same discipline as ROUND2-10: every section measures a byte-faithful +//! replica of the shipped code (BEFORE) against the candidate shape +//! (AFTER), with an equivalence gate. An AFTER that doesn't win gets +//! rolled back instead of adopted. +//! +//! Sections (all pure CPU, no Postgres): +//! 1. REST download `FileDto` dead clone vs mime/size capture + move +//! 2. Single-resource GET/HEAD `Last-Modified`: chrono `to_rfc2822()` +//! vs `common::fmt::rfc2822_utc` stack render (gate: byte-identical) +//! 3. `/status.php` poll: rebuild `json!` + serialize vs `OnceLock` +//! (gate: byte-identical) +//! 4. NC chunk-upload session PROPFIND: `push_str(&format!)` + chrono +//! per chunk vs `with_capacity` + `write!` + stack dates +//! (gate: byte-identical XML) +//! 5. RateLimiter: 2 key allocs + entry+insert vs 1 alloc + single +//! `and_upsert_with` (gate: identical allow/deny + counts) +//! 6. CSRF header token: `to_string` vs borrow compare (gate: same bool) +//! 7. Thumbnail ETag: `{:?}` Debug enums vs `as_str` + push (gate: bytes) +//! 8. Recent-handler id: `Uuid::to_string` vs stack `encode_lower` +//! (gate: identical str) +//! 9. 4xx error body: status+message clones + `kind.to_string()` vs +//! borrowed single-alloc serialize (gate: byte-identical JSON) +//! 10. vCard emit: `push_str(&format!)` vs `write!` (gate: bytes) +//! 11. Search page slice: `.to_vec()` clone vs `drain` move (gate: equal) +//! 12. Content-hit verify: double `Uuid::parse_str` vs parse-once pairs +//! (gate: same verified set) +//! 13. Group last-user check: O(N·M) slice contains vs HashSet +//! (gate: same bool) +//! 14. Retry op label: eager `format!` vs lazy closure (success path) +//! 15. `encrypt_bytes`: ciphertext alloc + copy vs in-place detached +//! (gate: byte-identical output for a fixed nonce + round-trip) +//! 16. Encrypted `collect_stream`: `Vec::new()` growth vs pre-sized +//! (gate: same bytes) +//! 17. Face clustering `cosine`: per-pair norm recompute vs precomputed +//! sqrt norms (gate: bitwise-identical similarity + same unions) +//! 18. `/openapi.json`: rebuild + serialize vs `OnceLock` +//! (gate: byte-identical) +//! 19. `CalendarEventDto::from`: getter clones (incl. the ~11 KB +//! `ical_data`) vs `into_parts` move (gate: identical DTO fields) +//! 20. `StoragePath` row materialization: eager `Vec` segments + +//! duplicated `path_string` vs single canonical joined `String` +//! (gate: identical path/file_name/parent/Display) +//! +//! Run: cargo run --release --features bench --example bench_round11_micro +//! Tunables (env): BENCH_ITERS (100000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::fmt::Write as _; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +// ─── Counting allocator ───────────────────────────────────────────────────── + +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) +} + +fn measure(label: &str, iters: u64, mut f: impl FnMut() -> R) -> (f64, f64) { + for _ in 0..1000 { + black_box(f()); + } + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t0 = Instant::now(); + for _ in 0..iters { + black_box(f()); + } + let wall = t0.elapsed().as_secs_f64(); + let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64; + let ns = wall * 1e9 / iters as f64; + println!(" {label:<52} {ns:>10.1} ns/op {allocs:>8.3} allocs/op"); + (ns, allocs) +} + +fn gate(name: &str, ok: bool) { + if ok { + println!(" gate[{name}]: OK"); + } else { + println!(" gate[{name}]: FAILED — DO NOT SHIP THIS SECTION"); + } +} + +// ─── §1 download FileDto dead clone ───────────────────────────────────────── + +/// Field-faithful replica of `FileDto` (`application/dtos/file_dto.rs`). +#[derive(Clone)] +#[allow(dead_code)] +struct FileDtoRep { + 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, + sort_date: Option, + content_hash: String, + etag: String, + created_by: Option, + updated_by: Option, +} + +fn sample_file_dto() -> FileDtoRep { + FileDtoRep { + id: "0198c9a0-1111-7abc-9def-0123456789ab".into(), + name: "IMG_20260716_193245.jpg".into(), + path: "/Photos/2026/07/IMG_20260716_193245.jpg".into(), + size: 4_183_212, + mime_type: Arc::from("image/jpeg"), + folder_id: Some("0198c9a0-2222-7abc-9def-0123456789ab".into()), + created_at: 1_784_500_000, + modified_at: 1_784_500_020, + icon_class: Arc::from("fas fa-file-image"), + icon_special_class: Arc::from("image-icon"), + category: Arc::from("Image"), + size_formatted: "3.99 MB".into(), + sort_date: None, + content_hash: "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3".into(), + etag: "\"b3b3b3b3-1784500020\"".into(), + created_by: None, + updated_by: None, + } +} + +/// The downstream service consumes the DTO (as `get_file_optimized_preloaded` +/// does) and returns it (discarded by the handler as `_file`). +#[inline(never)] +fn service_consume(dto: FileDtoRep) -> (FileDtoRep, u64) { + let s = dto.size; + (dto, s) +} + +fn section_1(iters: u64) { + println!(" §1 REST download FileDto hand-off (per download)"); + let dto = sample_file_dto(); + + // The handler owns `file_dto` (fetched per request) in both shapes; the + // arms isolate ONLY the hand-off into `get_file_optimized_preloaded`. + // BEFORE: `file_dto.clone()` in, then read mime/size from the retained + // copy. AFTER: capture mime (Arc bump) + size, MOVE the DTO in. + measure("BEFORE dead clone into service", iters, || { + let (ret, _s) = service_consume(dto.clone()); + drop(ret); + (dto.mime_type.clone(), dto.size) + }); + measure("AFTER capture mime/size + move", iters, || { + // Model the move without giving up the corpus DTO: production moves + // the request-owned value; the captures are the only per-call work. + let mime = dto.mime_type.clone(); + let size = dto.size; + black_box((&dto, mime, size)).1 + }); +} + +// ─── §2 Last-Modified header value ────────────────────────────────────────── + +fn section_2(iters: u64) { + println!(" §2 GET/HEAD Last-Modified render (per response)"); + let ts: i64 = 1_784_500_020; + + let before = chrono::DateTime::::from_timestamp(ts, 0) + .unwrap_or_else(chrono::Utc::now) + .to_rfc2822(); + let mut buf = [0u8; 31]; + let after = oxicloud::common::fmt::rfc2822_utc(&mut buf, ts) + .map(str::to_owned) + .unwrap_or_default(); + gate("rfc2822 bytes identical", before == after); + + measure("BEFORE chrono to_rfc2822", iters, || { + chrono::DateTime::::from_timestamp(black_box(ts), 0) + .unwrap_or_else(chrono::Utc::now) + .to_rfc2822() + }); + measure("AFTER fmt::rfc2822_utc + header alloc", iters, || { + let mut b = [0u8; 31]; + oxicloud::common::fmt::rfc2822_utc(&mut b, black_box(ts)) + .map(str::to_owned) + .unwrap_or_default() + }); + measure("AFTER fmt::rfc2822_utc stack only", iters, || { + let mut b = [0u8; 31]; + oxicloud::common::fmt::rfc2822_utc(&mut b, black_box(ts)).map(|s| s.len()) + }); +} + +// ─── §3 /status.php ───────────────────────────────────────────────────────── + +fn build_status_json(major: u32, minor: u32, patch: u32, version_string: &str) -> Vec { + let v = serde_json::json!({ + "installed": true, + "maintenance": false, + "needsDbUpgrade": false, + "version": format!("{}.{}.{}.1", major, minor, patch), + "versionstring": version_string, + "productname": "OxiCloud", + "edition": "" + }); + serde_json::to_vec(&v).expect("status json") +} + +fn section_3(iters: u64) { + println!(" §3 /status.php poll (per request)"); + let (maj, min, pat) = (31u32, 0u32, 0u32); + let vs = "31.0.0"; + + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + let cached = CACHED.get_or_init(|| bytes::Bytes::from(build_status_json(maj, min, pat, vs))); + gate( + "status body identical", + cached.as_ref() == build_status_json(maj, min, pat, vs).as_slice(), + ); + + measure("BEFORE rebuild json! + serialize", iters, || { + build_status_json(black_box(maj), min, pat, black_box(vs)) + }); + measure("AFTER OnceLock refcount bump", iters, || { + CACHED.get().unwrap().clone() + }); +} + +// ─── §4 NC chunk-upload session PROPFIND ──────────────────────────────────── + +/// Replica of `uploads_handler::xml_escape` semantics (escape into owned +/// String only when needed). +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +struct ChunkRep { + name: String, + size: u64, + mtime: u64, +} + +fn chunk_listing(n: usize) -> (String, u64, Vec) { + let chunks = (0..n) + .map(|i| ChunkRep { + name: format!("{:05}", i + 1), + size: 10 * 1024 * 1024, + mtime: 1_784_500_000 + i as u64, + }) + .collect(); + ("admin".to_string(), 1_784_500_000, chunks) +} + +fn propfind_before(raw_username: &str, upload_id: &str, mtime: u64, chunks: &[ChunkRep]) -> String { + let session_href = format!("/remote.php/dav/uploads/{}/{}/", raw_username, upload_id); + let session_last_modified = chrono::DateTime::::from_timestamp(mtime as i64, 0) + .unwrap_or_else(chrono::Utc::now) + .to_rfc2822(); + + let mut body = String::new(); + body.push_str(r#""#); + body.push_str(r#""#); + body.push_str(""); + body.push_str(&format!("{}", xml_escape(&session_href))); + body.push_str(""); + body.push_str(""); + body.push_str(&format!( + "{}", + xml_escape(&session_last_modified) + )); + body.push_str("HTTP/1.1 200 OK"); + body.push_str(""); + + for chunk in chunks { + let chunk_href = format!( + "/remote.php/dav/uploads/{}/{}/{}", + raw_username, upload_id, chunk.name + ); + let chunk_modified = chrono::DateTime::::from_timestamp(chunk.mtime as i64, 0) + .unwrap_or_else(chrono::Utc::now) + .to_rfc2822(); + + body.push_str(""); + body.push_str(&format!("{}", xml_escape(&chunk_href))); + body.push_str(""); + body.push_str(""); + body.push_str(&format!( + "{}", + chunk.size + )); + body.push_str(&format!( + "{}", + xml_escape(&chunk_modified) + )); + body.push_str("HTTP/1.1 200 OK"); + body.push_str(""); + } + + body.push_str(""); + body +} + +/// Emit a `` element with the stack renderer, falling +/// back to chrono outside the 4-digit-year range (same fallback shape as +/// `nextcloud/webdav_handler.rs`). RFC 2822 output contains no +/// XML-special characters, so the escape pass is skipped by construction. +fn write_lastmodified(body: &mut String, secs: i64) { + let mut b = [0u8; 31]; + match oxicloud::common::fmt::rfc2822_utc(&mut b, secs) { + Some(s) => { + let _ = write!(body, "{}", s); + } + None => { + let dt = chrono::DateTime::::from_timestamp(secs, 0) + .unwrap_or_else(chrono::Utc::now) + .to_rfc2822(); + let _ = write!( + body, + "{}", + xml_escape(&dt) + ); + } + } +} + +fn propfind_after(raw_username: &str, upload_id: &str, mtime: u64, chunks: &[ChunkRep]) -> String { + let mut body = String::with_capacity(256 + chunks.len() * 256); + body.push_str(r#""#); + body.push_str(r#""#); + body.push_str(""); + let _ = write!( + body, + "/remote.php/dav/uploads/{}/{}/", + xml_escape(raw_username), + xml_escape(upload_id) + ); + body.push_str(""); + body.push_str(""); + write_lastmodified(&mut body, mtime as i64); + body.push_str("HTTP/1.1 200 OK"); + body.push_str(""); + + for chunk in chunks { + body.push_str(""); + let _ = write!( + body, + "/remote.php/dav/uploads/{}/{}/{}", + xml_escape(raw_username), + xml_escape(upload_id), + xml_escape(&chunk.name) + ); + body.push_str(""); + body.push_str(""); + let _ = write!( + body, + "{}", + chunk.size + ); + write_lastmodified(&mut body, chunk.mtime as i64); + body.push_str("HTTP/1.1 200 OK"); + body.push_str(""); + } + + body.push_str(""); + body +} + +fn section_4(iters: u64) { + println!(" §4 NC upload-session PROPFIND body (per PROPFIND)"); + for n in [16usize, 256] { + let (user, mtime, chunks) = chunk_listing(n); + let b = propfind_before(&user, "web-file-upload-abc123", mtime, &chunks); + let a = propfind_after(&user, "web-file-upload-abc123", mtime, &chunks); + gate(&format!("xml identical ({n} chunks)"), a == b); + let it = (iters / n as u64).max(50); + measure(&format!("BEFORE push_str(&format!) {n} chunks"), it, || { + propfind_before(&user, "web-file-upload-abc123", mtime, black_box(&chunks)) + }); + measure( + &format!("AFTER write! + capacity {n} chunks"), + it, + || propfind_after(&user, "web-file-upload-abc123", mtime, black_box(&chunks)), + ); + } +} + +// ─── §5 RateLimiter ───────────────────────────────────────────────────────── + +fn section_5(iters: u64) { + println!(" §5 RateLimiter check_and_increment (per limited request)"); + let cache_b: moka::sync::Cache = moka::sync::Cache::builder() + .time_to_live(std::time::Duration::from_secs(60)) + .max_capacity(10_000) + .build(); + let cache_a: moka::sync::Cache = moka::sync::Cache::builder() + .time_to_live(std::time::Duration::from_secs(60)) + .max_capacity(10_000) + .build(); + let ip = "203.0.113.42"; + + // Equivalence gate: identical counter sequences over a fresh key. + let seq_b: Vec = (0..5) + .map(|_| { + let c = cache_b + .entry(ip.to_string()) + .or_insert_with(|| 0) + .into_value() + + 1; + cache_b.insert(ip.to_string(), c); + c + }) + .collect(); + let seq_a: Vec = (0..5) + .map(|_| { + cache_a + .entry(ip.to_string()) + .and_upsert_with(|e| e.map(|v| v.into_value() + 1).unwrap_or(1)) + .into_value() + }) + .collect(); + gate("counter sequence identical", seq_a == seq_b); + cache_a.invalidate(ip); + cache_b.invalidate(ip); + + // Gate for the get+insert variant: identical counter sequence. + let cache_c: moka::sync::Cache = moka::sync::Cache::builder() + .time_to_live(std::time::Duration::from_secs(60)) + .max_capacity(10_000) + .build(); + let seq_c: Vec = (0..5) + .map(|_| { + let count = cache_c.get(ip).unwrap_or(0) + 1; + cache_c.insert(ip.to_string(), count); + count + }) + .collect(); + gate("get+insert sequence identical", seq_c == seq_b); + cache_c.invalidate(ip); + + measure("BEFORE 2 allocs + entry+insert", iters, || { + let key = ip.to_string(); + let count = cache_b.entry(key).or_insert_with(|| 0).into_value() + 1; + cache_b.insert(ip.to_string(), count); + count + }); + measure("AFTER-1 and_upsert_with", iters, || { + cache_a + .entry(ip.to_string()) + .and_upsert_with(|e| e.map(|v| v.into_value() + 1).unwrap_or(1)) + .into_value() + }); + measure("AFTER-2 lock-free get + insert", iters, || { + let count = cache_c.get(black_box(ip)).unwrap_or(0) + 1; + cache_c.insert(ip.to_string(), count); + count + }); +} + +// ─── §6 CSRF header token ─────────────────────────────────────────────────── + +fn section_6(iters: u64) { + println!(" §6 CSRF token compare (per state-changing cookie request)"); + let cookie_token = Some("9f8e7d6c5b4a39281706f5e4d3c2b1a0".to_string()); + let header_val = "9f8e7d6c5b4a39281706f5e4d3c2b1a0"; + + let before = { + let header_token = Some(header_val).map(|s| s.to_string()); + matches!((cookie_token.as_ref(), header_token.as_ref()), + (Some(c), Some(h)) if !c.is_empty() && c == h) + }; + let after = { + let header_token: Option<&str> = Some(header_val); + matches!((cookie_token.as_ref(), header_token), + (Some(c), Some(h)) if !c.is_empty() && c == h) + }; + gate("csrf verdict identical", before == after); + + measure("BEFORE header to_string + compare", iters, || { + let header_token = Some(black_box(header_val)).map(|s| s.to_string()); + matches!((cookie_token.as_ref(), header_token.as_ref()), + (Some(c), Some(h)) if !c.is_empty() && c == h) + }); + measure("AFTER borrow compare", iters, || { + let header_token: Option<&str> = Some(black_box(header_val)); + matches!((cookie_token.as_ref(), header_token), + (Some(c), Some(h)) if !c.is_empty() && c == h) + }); +} + +// ─── §7 Thumbnail ETag ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +enum SizeRep { + Icon, + Preview, + Large, +} +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +enum FormatRep { + Webp, + Jpeg, +} +impl SizeRep { + fn as_str(self) -> &'static str { + match self { + SizeRep::Icon => "Icon", + SizeRep::Preview => "Preview", + SizeRep::Large => "Large", + } + } +} +impl FormatRep { + fn as_str(self) -> &'static str { + match self { + FormatRep::Webp => "Webp", + FormatRep::Jpeg => "Jpeg", + } + } +} + +fn section_7(iters: u64) { + println!(" §7 thumbnail ETag build (per thumbnail request)"); + let id = "0198c9a0-1111-7abc-9def-0123456789ab"; + let (sz, fm) = (SizeRep::Preview, FormatRep::Webp); + + let before = format!("\"thumb-{}-{:?}-{:?}\"", id, sz, fm); + let after = { + let mut s = String::with_capacity(9 + id.len() + sz.as_str().len() + fm.as_str().len()); + s.push_str("\"thumb-"); + s.push_str(id); + s.push('-'); + s.push_str(sz.as_str()); + s.push('-'); + s.push_str(fm.as_str()); + s.push('"'); + s + }; + gate("etag bytes identical", before == after); + + measure("BEFORE format! with {:?} enums", iters, || { + format!("\"thumb-{}-{:?}-{:?}\"", black_box(id), sz, fm) + }); + measure("AFTER as_str + sized push", iters, || { + let id = black_box(id); + let mut s = String::with_capacity(9 + id.len() + sz.as_str().len() + fm.as_str().len()); + s.push_str("\"thumb-"); + s.push_str(id); + s.push('-'); + s.push_str(sz.as_str()); + s.push('-'); + s.push_str(fm.as_str()); + s.push('"'); + s + }); +} + +// ─── §8 recent-handler id round-trip ──────────────────────────────────────── + +fn section_8(iters: u64) { + println!(" §8 recent-handler item_id hand-off (per record/remove)"); + let id = uuid::Uuid::parse_str("0198c9a0-1111-7abc-9def-0123456789ab").unwrap(); + + let before = id.to_string(); + let mut buf = [0u8; 36]; + let after: &str = id.as_hyphenated().encode_lower(&mut buf); + gate("id str identical", before == after); + + measure("BEFORE Uuid::to_string per call", iters, || { + let s = black_box(id).to_string(); + s.len() + }); + measure("AFTER stack encode_lower", iters, || { + let mut b = [0u8; 36]; + let s: &str = black_box(id).as_hyphenated().encode_lower(&mut b); + s.len() + }); +} + +// ─── §9 4xx error body ────────────────────────────────────────────────────── + +#[derive(serde::Serialize)] +struct ErrorResponseOwned { + status: String, + error: String, + message: String, + error_type: String, +} + +#[derive(serde::Serialize)] +struct ErrorResponseBorrowed<'a> { + status: &'a str, + error: &'a str, + message: &'a str, + error_type: &'static str, +} + +fn section_9(iters: u64) { + println!(" §9 4xx error response build (per 404/401/403)"); + // Model: DomainError::not_found("File", id) → AppError → into_response. + let entity = "File"; + let id = "0198c9a0-1111-7abc-9def-0123456789ab"; + let status = axum::http::StatusCode::NOT_FOUND; + + let before_bytes = { + // not_found: id.clone() + eager format! + let idc = id.to_string(); + let _entity_id = Some(idc.clone()); + let message = format!("{} not found: {}", entity, idc); + // From: kind.to_string() + let error_type = "Not Found".to_string(); + // into_response: status.to_string() + message.clone() + let body = ErrorResponseOwned { + status: status.to_string(), + error: message.clone(), + message, + error_type, + }; + serde_json::to_vec(&body).unwrap() + }; + let after_bytes = { + let idc = id.to_string(); + let message = format!("{} not found: {}", entity, idc); + let _entity_id = Some(idc); + let status_s = status.to_string(); + let body = ErrorResponseBorrowed { + status: &status_s, + error: &message, + message: &message, + error_type: "Not Found", + }; + serde_json::to_vec(&body).unwrap() + }; + gate("error JSON identical", before_bytes == after_bytes); + + measure("BEFORE clones + owned serialize", iters, || { + let idc = black_box(id).to_string(); + let _entity_id = Some(idc.clone()); + let message = format!("{} not found: {}", black_box(entity), idc); + let error_type = "Not Found".to_string(); + let body = ErrorResponseOwned { + status: status.to_string(), + error: message.clone(), + message, + error_type, + }; + serde_json::to_vec(&body).unwrap() + }); + measure("AFTER move + borrowed serialize", iters, || { + let idc = black_box(id).to_string(); + let message = format!("{} not found: {}", black_box(entity), idc); + let _entity_id = Some(idc); + let status_s = status.to_string(); + let body = ErrorResponseBorrowed { + status: &status_s, + error: &message, + message: &message, + error_type: "Not Found", + }; + serde_json::to_vec(&body).unwrap() + }); +} + +// ─── §10 vCard emit ───────────────────────────────────────────────────────── + +struct ContactRep { + full_name: String, + first: String, + last: String, + email_home: String, + email_work: String, + phone: String, + org: String, + title: String, + uid: String, +} + +fn sample_contact() -> ContactRep { + ContactRep { + full_name: "Ada Lovelace".into(), + first: "Ada".into(), + last: "Lovelace".into(), + email_home: "ada@example.org".into(), + email_work: "ada@analytical.engines".into(), + phone: "+44 20 7946 0958".into(), + org: "Analytical Engines Ltd".into(), + title: "Chief Mathematician".into(), + uid: "0198c9a0-3333-7abc-9def-0123456789ab".into(), + } +} + +fn vcard_before(c: &ContactRep) -> String { + let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + vcard.push_str(&format!("FN:{}\r\n", c.full_name)); + vcard.push_str(&format!("N:{};{};;;\r\n", c.last, c.first)); + vcard.push_str(&format!("EMAIL;TYPE=HOME:{}\r\n", c.email_home)); + vcard.push_str(&format!("EMAIL;TYPE=WORK:{}\r\n", c.email_work)); + vcard.push_str(&format!("TEL;TYPE=CELL:{}\r\n", c.phone)); + vcard.push_str(&format!("ORG:{}\r\n", c.org)); + vcard.push_str(&format!("TITLE:{}\r\n", c.title)); + vcard.push_str(&format!("UID:{}\r\n", c.uid)); + vcard.push_str("END:VCARD\r\n"); + vcard +} + +fn vcard_after(c: &ContactRep) -> String { + let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + let _ = write!(vcard, "FN:{}\r\n", c.full_name); + let _ = write!(vcard, "N:{};{};;;\r\n", c.last, c.first); + let _ = write!(vcard, "EMAIL;TYPE=HOME:{}\r\n", c.email_home); + let _ = write!(vcard, "EMAIL;TYPE=WORK:{}\r\n", c.email_work); + let _ = write!(vcard, "TEL;TYPE=CELL:{}\r\n", c.phone); + let _ = write!(vcard, "ORG:{}\r\n", c.org); + let _ = write!(vcard, "TITLE:{}\r\n", c.title); + let _ = write!(vcard, "UID:{}\r\n", c.uid); + vcard.push_str("END:VCARD\r\n"); + vcard +} + +fn section_10(iters: u64) { + println!(" §10 vCard emit (per contact create/update)"); + let c = sample_contact(); + gate("vcard bytes identical", vcard_before(&c) == vcard_after(&c)); + measure("BEFORE push_str(&format!) per line", iters, || { + vcard_before(black_box(&c)) + }); + measure("AFTER write! per line", iters, || { + vcard_after(black_box(&c)) + }); +} + +// ─── §11 search page slice ────────────────────────────────────────────────── + +#[derive(Clone, PartialEq, Debug)] +#[allow(dead_code)] +struct SearchHitRep { + id: String, + name: String, + path: String, + etag: String, + content_hash: String, + size_formatted: String, + size: u64, +} + +fn search_corpus(n: usize) -> Vec { + (0..n) + .map(|i| SearchHitRep { + id: format!("0198c9a0-1111-7abc-9def-{:012}", i), + name: format!("Informe anual {i}.pdf"), + path: format!("/Documentos/2026/Informe anual {i}.pdf"), + etag: format!("\"e{i}-1784500020\""), + content_hash: "b3".repeat(32), + size_formatted: "1.24 MB".into(), + size: 1_300_000 + i as u64, + }) + .collect() +} + +fn section_11(iters: u64) { + println!(" §11 search page extraction (per uncached query, 50-item page)"); + let full = search_corpus(400); + let (start, end) = (100usize, 150usize); + + let before_page = full[start..end].to_vec(); + let after_page: Vec = { + let own = full.clone(); + own.into_iter().skip(start).take(end - start).collect() + }; + gate("page contents identical", before_page == after_page); + + // Both arms pay the identical own-clone (the service owns the enriched + // vec in production); the delta is page extraction: deep-clone the + // slice + drop the whole vec, vs consume the vec moving the page out. + let it = (iters / 50).max(100); + measure("BEFORE slice.to_vec() (clones page)", it, || { + let own = full.clone(); + let page = own[start..end].to_vec(); + (own.len(), page) + }); + measure("AFTER into_iter skip/take (moves)", it, || { + let own = full.clone(); + let n = own.len(); + let page: Vec = own.into_iter().skip(start).take(end - start).collect(); + (n, page) + }); +} + +// ─── §12 content-hit double parse ─────────────────────────────────────────── + +fn section_12(iters: u64) { + println!(" §12 content-hit verify loop (per content search, 100 hits)"); + let hits: Vec = (0..100) + .map(|i| format!("0198c9a0-1111-7abc-9def-{:012}", i)) + .collect(); + let allowed: std::collections::HashSet = hits + .iter() + .step_by(2) + .map(|s| uuid::Uuid::parse_str(s).unwrap()) + .collect(); + + let before: Vec<&String> = { + let mut ids = Vec::with_capacity(hits.len()); + for h in &hits { + if let Ok(u) = uuid::Uuid::parse_str(h) { + ids.push(u); + } + } + hits.iter() + .filter(|h| { + uuid::Uuid::parse_str(h) + .map(|u| allowed.contains(&u)) + .unwrap_or(false) + }) + .collect() + }; + let after: Vec<&String> = { + let pairs: Vec<(&String, uuid::Uuid)> = hits + .iter() + .filter_map(|h| uuid::Uuid::parse_str(h).ok().map(|u| (h, u))) + .collect(); + pairs + .iter() + .filter(|(_, u)| allowed.contains(u)) + .map(|(h, _)| *h) + .collect() + }; + gate("verified set identical", before == after); + + let it = (iters / 100).max(100); + measure("BEFORE parse twice per hit", it, || { + let mut ids = Vec::with_capacity(hits.len()); + for h in &hits { + if let Ok(u) = uuid::Uuid::parse_str(h) { + ids.push(u); + } + } + black_box(&ids); + let v: Vec<&String> = hits + .iter() + .filter(|h| { + uuid::Uuid::parse_str(h) + .map(|u| allowed.contains(&u)) + .unwrap_or(false) + }) + .collect(); + v.len() + }); + measure("AFTER parse once, carry pairs", it, || { + let pairs: Vec<(&String, uuid::Uuid)> = hits + .iter() + .filter_map(|h| uuid::Uuid::parse_str(h).ok().map(|u| (h, u))) + .collect(); + let ids: Vec = pairs.iter().map(|(_, u)| *u).collect(); + black_box(&ids); + let v: Vec<&String> = pairs + .iter() + .filter(|(_, u)| allowed.contains(u)) + .map(|(h, _)| *h) + .collect(); + v.len() + }); +} + +// ─── §13 group last-user containment ──────────────────────────────────────── + +fn section_13(iters: u64) { + println!(" §13 group last-user check (per group edit, 500×500)"); + let before_users: Vec = (0..500).map(|_| uuid::Uuid::new_v4()).collect(); + let mut child_users = before_users.clone(); + child_users.rotate_left(250); + + let b = before_users.iter().all(|u| child_users.contains(u)); + let set: std::collections::HashSet<&uuid::Uuid> = child_users.iter().collect(); + let a = before_users.iter().all(|u| set.contains(u)); + gate("verdict identical", a == b); + + let it = (iters / 500).max(50); + measure("BEFORE O(N·M) slice contains", it, || { + before_users + .iter() + .all(|u| black_box(&child_users).contains(u)) + }); + measure("AFTER HashSet build + probe", it, || { + let s: std::collections::HashSet<&uuid::Uuid> = black_box(&child_users).iter().collect(); + before_users.iter().all(|u| s.contains(u)) + }); +} + +// ─── §14 retry label ──────────────────────────────────────────────────────── + +fn retry_sync_before(name: &str, f: impl Fn() -> u64) -> u64 { + // success path: label was allocated by the caller, never read + black_box(name); + f() +} +fn retry_sync_after(_name: impl Fn() -> String, f: impl Fn() -> u64) -> u64 { + f() +} + +fn section_14(iters: u64) { + println!(" §14 retry op-label (per blob op, success path)"); + let hash = "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3"; + measure("BEFORE eager format! label", iters, || { + retry_sync_before(&format!("get_blob_stream({})", black_box(hash)), || 7) + }); + measure("AFTER lazy closure label", iters, || { + retry_sync_after(|| format!("get_blob_stream({})", black_box(hash)), || 7) + }); +} + +// ─── §15/16 encrypted backend ─────────────────────────────────────────────── + +fn section_15_16(iters: u64) { + use aes_gcm::aead::{Aead, AeadInPlace, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + + println!(" §15 encrypt_bytes (per encrypted chunk write, 256 KiB)"); + const NONCE_SIZE: usize = 12; + let key = [7u8; 32]; + let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); + let data = vec![0xA5u8; 256 * 1024]; + let nonce_fixed = [9u8; 12]; + let nonce = Nonce::from_slice(&nonce_fixed); + + // BEFORE: cipher.encrypt allocates ciphertext; copied again after nonce. + let before_out = { + let ciphertext = cipher.encrypt(nonce, data.as_slice()).unwrap(); + let mut encrypted = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + encrypted.extend_from_slice(&nonce_fixed); + encrypted.extend_from_slice(&ciphertext); + encrypted + }; + // AFTER: single buffer, in-place detached encrypt, append tag. + let after_out = { + let mut out = Vec::with_capacity(NONCE_SIZE + data.len() + 16); + out.extend_from_slice(&nonce_fixed); + out.extend_from_slice(&data); + let tag = cipher + .encrypt_in_place_detached(nonce, b"", &mut out[NONCE_SIZE..]) + .unwrap(); + out.extend_from_slice(&tag); + out + }; + gate( + "ciphertext identical (fixed nonce)", + before_out == after_out, + ); + // Round-trip through the decrypt shape used in production. + let rt = { + let mut enc = after_out.clone(); + let ct = enc.split_off(NONCE_SIZE); + let n = Nonce::from_slice(&enc); + let mut ct = ct; + cipher.decrypt_in_place(n, b"", &mut ct).unwrap(); + ct + }; + gate("decrypt round-trip", rt == data); + + let it = (iters / 100).max(200); + measure("BEFORE encrypt + second copy", it, || { + let ciphertext = cipher.encrypt(nonce, black_box(data.as_slice())).unwrap(); + let mut encrypted = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + encrypted.extend_from_slice(&nonce_fixed); + encrypted.extend_from_slice(&ciphertext); + encrypted + }); + measure("AFTER in-place detached", it, || { + let data = black_box(data.as_slice()); + let mut out = Vec::with_capacity(NONCE_SIZE + data.len() + 16); + out.extend_from_slice(&nonce_fixed); + out.extend_from_slice(data); + let tag = cipher + .encrypt_in_place_detached(nonce, b"", &mut out[NONCE_SIZE..]) + .unwrap(); + out.extend_from_slice(&tag); + out + }); + + println!(" §16 collect_stream buffer growth (1 MiB blob, 4 KiB frames)"); + let frames: Vec> = (0..256).map(|i| vec![i as u8; 4096]).collect(); + let expect: Vec = frames.iter().flatten().copied().collect(); + + let before_buf = { + let mut buf = Vec::new(); + for f in &frames { + buf.extend_from_slice(f); + } + buf + }; + let after_buf = { + let mut buf: Vec = Vec::new(); + for f in &frames { + if buf.capacity() == 0 { + buf.reserve(1024 * 1024 + 28); + } + buf.extend_from_slice(f); + } + buf + }; + gate( + "collected bytes identical", + before_buf == expect && after_buf == expect, + ); + + let it = (iters / 100).max(200); + measure("BEFORE Vec::new() growth", it, || { + let mut buf = Vec::new(); + for f in black_box(&frames) { + buf.extend_from_slice(f); + } + buf + }); + measure("AFTER reserve on first frame", it, || { + let mut buf: Vec = Vec::new(); + for f in black_box(&frames) { + if buf.capacity() == 0 { + buf.reserve(1024 * 1024 + 28); + } + buf.extend_from_slice(f); + } + buf + }); +} + +// ─── §17 cosine norms ─────────────────────────────────────────────────────── + +fn cosine_before(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32); + for (&x, &y) in a.iter().zip(b.iter()) { + dot += x * y; + na += x * x; + nb += y * y; + } + if na == 0.0 || nb == 0.0 { + return 0.0; + } + dot / (na.sqrt() * nb.sqrt()) +} + +/// AFTER: norms precomputed once per face (same accumulation order), the +/// pair loop keeps only the dot product. The final expression keeps the +/// exact `dot / (sqrt(na) * sqrt(nb))` arithmetic, so results are +/// bit-identical to the BEFORE. +fn norm_sq(v: &[f32]) -> f32 { + let mut n = 0.0f32; + for &x in v { + n += x * x; + } + n +} +fn cosine_after(a: &[f32], b: &[f32], na: f32, nb: f32) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let mut dot = 0.0f32; + for (&x, &y) in a.iter().zip(b.iter()) { + dot += x * y; + } + if na == 0.0 || nb == 0.0 { + return 0.0; + } + dot / (na.sqrt() * nb.sqrt()) +} + +fn section_17(iters: u64) { + println!(" §17 recluster cosine pass (200 faces × 512-dim)"); + let n = 200usize; + let mut state = 0x12345678u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state % 2000) as f32 / 1000.0 - 1.0 + }; + let faces: Vec> = (0..n).map(|_| (0..512).map(|_| next()).collect()).collect(); + + // Gate: bit-identical similarity over every pair. + let norms: Vec = faces.iter().map(|f| norm_sq(f)).collect(); + let mut identical = true; + for i in 0..n { + for j in (i + 1)..n { + let b = cosine_before(&faces[i], &faces[j]); + let a = cosine_after(&faces[i], &faces[j], norms[i], norms[j]); + if a.to_bits() != b.to_bits() { + identical = false; + } + } + } + gate("similarity bit-identical (all pairs)", identical); + + let it = (iters / 20_000).max(3); + measure("BEFORE per-pair norms", it, || { + let mut acc = 0.0f32; + for i in 0..n { + for j in (i + 1)..n { + acc += cosine_before(black_box(&faces[i]), &faces[j]); + } + } + acc + }); + measure("AFTER precomputed norms", it, || { + let norms: Vec = faces.iter().map(|f| norm_sq(f)).collect(); + let mut acc = 0.0f32; + for i in 0..n { + for j in (i + 1)..n { + acc += cosine_after(black_box(&faces[i]), &faces[j], norms[i], norms[j]); + } + } + acc + }); +} + +// ─── §18 /openapi.json ────────────────────────────────────────────────────── + +fn section_18(iters: u64) { + println!(" §18 /openapi.json (per request)"); + use utoipa::OpenApi as _; + let built = oxicloud::interfaces::api::ApiDoc::openapi(); + let baseline = serde_json::to_vec(&built).unwrap(); + + static SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let cached = SPEC.get_or_init(|| { + bytes::Bytes::from( + serde_json::to_vec(&oxicloud::interfaces::api::ApiDoc::openapi()).unwrap(), + ) + }); + gate( + "spec bytes identical", + cached.as_ref() == baseline.as_slice(), + ); + println!(" (spec size: {} KiB)", baseline.len() / 1024); + + let it = (iters / 2000).max(20); + measure("BEFORE rebuild ApiDoc + serialize", it, || { + serde_json::to_vec(&oxicloud::interfaces::api::ApiDoc::openapi()) + .unwrap() + .len() + }); + measure("AFTER OnceLock bump", iters, || { + SPEC.get().unwrap().clone().len() + }); +} + +// ─── §19 CalendarEventDto move ────────────────────────────────────────────── + +#[allow(dead_code)] +struct EventRep { + id: uuid::Uuid, + calendar_id: uuid::Uuid, + summary: String, + description: Option, + location: Option, + start: i64, + end: i64, + all_day: bool, + rrule: Option, + ical_uid: String, + ical_data: String, +} + +#[allow(dead_code)] +struct EventDtoRep { + id: String, + calendar_id: String, + summary: String, + description: Option, + location: Option, + start: i64, + end: i64, + all_day: bool, + rrule: Option, + ical_uid: String, + ical_data: String, +} + +fn sample_event(ical_kb: usize) -> EventRep { + EventRep { + id: uuid::Uuid::new_v4(), + calendar_id: uuid::Uuid::new_v4(), + summary: "Reunión trimestral de resultados".into(), + description: Some("Orden del día: revisión de métricas, hoja de ruta.".into()), + location: Some("Sala Turing, 3ª planta".into()), + start: 1_784_500_000, + end: 1_784_503_600, + all_day: false, + rrule: Some("FREQ=MONTHLY;BYDAY=1MO".into()), + ical_uid: "evt-0198c9a0@oxicloud".into(), + ical_data: format!( + "BEGIN:VEVENT\r\nUID:evt@x\r\nSUMMARY:Reunión\r\n{}END:VEVENT\r\n", + "ATTENDEE;CN=Persona;PARTSTAT=ACCEPTED:mailto:p@example.org\r\n".repeat(ical_kb * 16) + ), + } +} + +fn dto_before(e: &EventRep) -> EventDtoRep { + EventDtoRep { + id: e.id.to_string(), + calendar_id: e.calendar_id.to_string(), + summary: e.summary.as_str().to_string(), + description: e.description.as_deref().map(|s| s.to_string()), + location: e.location.as_deref().map(|s| s.to_string()), + start: e.start, + end: e.end, + all_day: e.all_day, + rrule: e.rrule.as_deref().map(|s| s.to_string()), + ical_uid: e.ical_uid.as_str().to_string(), + ical_data: e.ical_data.as_str().to_string(), + } +} + +fn dto_after(e: EventRep) -> EventDtoRep { + EventDtoRep { + id: e.id.to_string(), + calendar_id: e.calendar_id.to_string(), + summary: e.summary, + description: e.description, + location: e.location, + start: e.start, + end: e.end, + all_day: e.all_day, + rrule: e.rrule, + ical_uid: e.ical_uid, + ical_data: e.ical_data, + } +} + +fn section_19(iters: u64) { + println!(" §19 CalendarEventDto::from (per event, 11 KiB ical_data)"); + let ev = sample_event(11); + let b = dto_before(&ev); + let a = dto_after(sample_event_clone(&ev)); + gate( + "dto fields identical", + b.summary == a.summary && b.ical_data == a.ical_data && b.id == a.id, + ); + + let it = (iters / 20).max(500); + measure("BEFORE getter clones (11 KiB copy)", it, || { + // model: adapter owns the entity (fetched row), converts, drops it + let owned = sample_event_clone(&ev); + let dto = dto_before(&owned); + drop(owned); + dto.ical_data.len() + }); + measure("AFTER into_parts move", it, || { + let owned = sample_event_clone(&ev); + let dto = dto_after(owned); + dto.ical_data.len() + }); +} + +fn sample_event_clone(e: &EventRep) -> EventRep { + EventRep { + id: e.id, + calendar_id: e.calendar_id, + summary: e.summary.clone(), + description: e.description.clone(), + location: e.location.clone(), + start: e.start, + end: e.end, + all_day: e.all_day, + rrule: e.rrule.clone(), + ical_uid: e.ical_uid.clone(), + ical_data: e.ical_data.clone(), + } +} + +// ─── §20 StoragePath row materialization ──────────────────────────────────── + +/// BEFORE replica: `StoragePath { segments: Vec }` + +/// `from_folder_and_name` building joined AND per-segment Strings, with the +/// entity retaining BOTH `storage_path` and `path_string` (the current +/// shipped shape). +mod sp_before { + pub struct StoragePathRep { + pub segments: Vec, + } + fn is_safe(s: &str) -> bool { + !s.is_empty() && s != "." && s != ".." && !s.contains('/') + } + pub fn from_folder_and_name( + folder_path: Option<&str>, + file_name: &str, + ) -> (StoragePathRep, String) { + let fp = folder_path.unwrap_or(""); + let mut joined = String::with_capacity(fp.len() + file_name.len() + 2); + let mut segments: Vec = + Vec::with_capacity(fp.bytes().filter(|&b| b == b'/').count() + 2); + for seg in fp + .split('/') + .chain(file_name.split('/')) + .filter(|s| is_safe(s)) + { + joined.push('/'); + joined.push_str(seg); + segments.push(seg.to_string()); + } + if segments.is_empty() { + joined.push('/'); + } + (StoragePathRep { segments }, joined) + } + pub struct EntityRep { + pub storage_path: StoragePathRep, + pub path_string: String, + #[allow(dead_code)] + pub name: String, + } + impl EntityRep { + pub fn file_name(&self) -> Option { + self.storage_path.segments.last().cloned() + } + pub fn display(&self) -> String { + if self.storage_path.segments.is_empty() { + return "/".to_string(); + } + let mut s = String::new(); + for seg in &self.storage_path.segments { + s.push('/'); + s.push_str(seg); + } + s + } + } +} + +/// AFTER shape: canonical joined `String` only; segments derived on demand. +mod sp_after { + pub struct StoragePathRep { + joined: String, + } + fn is_safe(s: &str) -> bool { + !s.is_empty() && s != "." && s != ".." && !s.contains('/') + } + impl StoragePathRep { + pub fn from_folder_and_name(folder_path: Option<&str>, file_name: &str) -> Self { + let fp = folder_path.unwrap_or(""); + let mut joined = String::with_capacity(fp.len() + file_name.len() + 2); + for seg in fp + .split('/') + .chain(file_name.split('/')) + .filter(|s| is_safe(s)) + { + joined.push('/'); + joined.push_str(seg); + } + if joined.is_empty() { + joined.push('/'); + } + Self { joined } + } + pub fn as_joined(&self) -> &str { + &self.joined + } + pub fn into_joined(self) -> String { + self.joined + } + pub fn file_name(&self) -> Option { + if self.joined == "/" { + None + } else { + self.joined.rsplit('/').next().map(str::to_string) + } + } + } + pub struct EntityRep { + pub storage_path: StoragePathRep, + #[allow(dead_code)] + pub name: String, + } + impl EntityRep { + pub fn file_name(&self) -> Option { + self.storage_path.file_name() + } + pub fn display(&self) -> String { + self.storage_path.as_joined().to_string() + } + } +} + +fn section_20(iters: u64) { + println!(" §20 row → entity path materialization (500-row page, depth 4)"); + let rows: Vec<(String, String)> = (0..500) + .map(|i| { + ( + format!("/Fotos/2026/Julio/Viaje a la sierra {}", i % 7), + format!("IMG_2026{:04}.jpg", i), + ) + }) + .collect(); + + // Equivalence gates across representations. + let mut ok_path = true; + let mut ok_name = true; + let mut ok_disp = true; + for (fp, name) in &rows { + let (bsp, bjoined) = sp_before::from_folder_and_name(Some(fp), name); + let be = sp_before::EntityRep { + storage_path: bsp, + path_string: bjoined, + name: name.clone(), + }; + let asp = sp_after::StoragePathRep::from_folder_and_name(Some(fp), name); + let ae = sp_after::EntityRep { + storage_path: asp, + name: name.clone(), + }; + ok_path &= be.path_string == ae.storage_path.as_joined(); + ok_name &= be.file_name() == ae.file_name(); + ok_disp &= be.display() == ae.display(); + } + gate("path_string identical", ok_path); + gate("file_name identical", ok_name); + gate("display identical", ok_disp); + + let it = (iters / 500).max(100); + measure("BEFORE joined + Vec + dup", it, || { + let mut total = 0usize; + for (fp, name) in black_box(&rows) { + let (sp, joined) = sp_before::from_folder_and_name(Some(fp), name); + let e = sp_before::EntityRep { + storage_path: sp, + path_string: joined, + name: name.clone(), + }; + // DTO consumes the joined string (moved), segments dropped. + let dto_path = e.path_string; + total += dto_path.len(); + } + total + }); + measure("AFTER single canonical String", it, || { + let mut total = 0usize; + for (fp, name) in black_box(&rows) { + let sp = sp_after::StoragePathRep::from_folder_and_name(Some(fp), name); + let e = sp_after::EntityRep { + storage_path: sp, + name: name.clone(), + }; + let dto_path = e.storage_path.into_joined(); + total += dto_path.len(); + } + total + }); +} + +// ─── §21 display classifier fusion ────────────────────────────────────────── + +fn section_21(iters: u64) { + use oxicloud::application::dtos::display_helpers::{ + category_for, classify_display, icon_class_for, icon_special_class_for, + }; + println!(" §21 display triple-classify (per listing row)"); + + // Corpus spanning: specific MIME, prefix MIME, octet-stream + ext + // fallback (lower/UPPER), no-ext, >16-byte ext, non-ASCII ext, dotfile. + let corpus: &[(&str, &str)] = &[ + ("IMG_2026.JPG", "image/jpeg"), + ("informe.pdf", "application/pdf"), + ("main.rs", "application/octet-stream"), + ("ARCHIVO.TXT", ""), + ("setup.AppImage", "application/octet-stream"), + ("video.mkv", "video/x-matroska"), + ("script.PY", ""), + ("no_extension", "application/octet-stream"), + ("weird.extensionlongerthansixteen", ""), + ("acentuado.ñml", ""), + (".bashrc", "text/plain"), + ("data.json", "application/json"), + ("song.FLAC", "application/octet-stream"), + ]; + + // Gate 1: fused output identical to the three public classifiers. + let mut ok = true; + for (name, mime) in corpus { + let c = classify_display(name, mime); + ok &= c.icon_class == icon_class_for(name, mime) + && c.icon_special_class == icon_special_class_for(name, mime) + && c.category == category_for(name, mime); + } + gate("fused == three classifiers (corpus)", ok); + // Gate 2: the historical heap-lowered ext hits the same arms — for + // ≤16-byte exts the stack lowering equals `to_ascii_lowercase()`; a + // >16-byte ext must land on the same defaults the old `_` arms gave. + let long = classify_display("weird.extensionlongerthansixteen", ""); + gate( + "long-ext defaults match old `_` arms", + long.icon_class == "fas fa-file" + && long.icon_special_class.is_empty() + && long.category == "Document", + ); + + // BEFORE replica: per-classifier ext_of + heap to_ascii_lowercase (the + // shipped trees are shared, so the delta measured is exactly the + // plumbing the fusion removed). + fn ext_of(name: &str) -> Option<&str> { + let name = name.rsplit('/').next().unwrap_or(name); + let after_dot = name.rsplit('.').next()?; + if after_dot.len() == name.len() || after_dot.is_empty() { + return None; + } + Some(after_dot) + } + + let it = (iters / 10).max(1000); + measure("BEFORE 3× classify (heap ext on fallback)", it, || { + let mut acc = 0usize; + for (name, mime) in corpus { + // The pre-fusion code heap-lowercased the ext INSIDE each + // classifier, but only on rows that fell through to the + // extension fallback (generic/empty MIME) — replicate exactly + // that alloc profile next to the shared decision trees. + let falls_back = mime.is_empty() || *mime == "application/octet-stream"; + if falls_back { + let _l = ext_of(name).map(|e| e.to_ascii_lowercase()); + } + let a = icon_class_for(name, mime); + if falls_back { + let _l = ext_of(name).map(|e| e.to_ascii_lowercase()); + } + let b = icon_special_class_for(name, mime); + if falls_back { + let _l = ext_of(name).map(|e| e.to_ascii_lowercase()); + } + let c = category_for(name, mime); + acc += a.len() + b.len() + c.len(); + } + acc + }); + measure("AFTER fused classify_display", it, || { + let mut acc = 0usize; + for (name, mime) in corpus { + let c = classify_display(name, mime); + acc += c.icon_class.len() + c.icon_special_class.len() + c.category.len(); + } + acc + }); +} + +// ─── main ─────────────────────────────────────────────────────────────────── + +fn main() { + let iters: u64 = env_or("BENCH_ITERS", 100_000); + println!("bench_round11_micro — iters={iters}\n"); + + section_1(iters); + section_2(iters); + section_3(iters); + section_4(iters); + section_5(iters); + section_6(iters); + section_7(iters); + section_8(iters); + section_9(iters); + section_10(iters); + section_11(iters); + section_12(iters); + section_13(iters); + section_14(iters); + section_15_16(iters); + section_17(iters); + section_18(iters); + section_19(iters); + section_20(iters); + section_21(iters); + + println!("\ndone"); +} diff --git a/examples/bench_round11_queries.rs b/examples/bench_round11_queries.rs new file mode 100644 index 00000000..ec9dcbfe --- /dev/null +++ b/examples/bench_round11_queries.rs @@ -0,0 +1,727 @@ +//! Round-11 query-shape pack — BEFORE query shapes vs AFTER (needs Postgres). +//! +//! Sections: +//! 1. Deferred upload registration (the default REST upload path): +//! 3 round-trips (parent drive SELECT → INSERT → parent path SELECT, +//! the middle two re-reading the SAME folders row) vs the single +//! `WITH parent AS (…) INSERT … RETURNING` template `persist_file` +//! already uses. Gate: identical returned (path, drive) + identical +//! not-found semantics for a missing parent. +//! 2. Calendar/AddressBook/Playlist authz: the only `check()` arms with +//! no result cache — `role_grants` point query per check vs a moka +//! `direct_grant_cache` hit. Gate: same verdict + revocation flip +//! after invalidate. +//! 3. `expand_user` cache miss: `is_external` + recursive groups CTE +//! awaited serially vs `tokio::join!`. Gate: same result set. +//! 4. Places geo clusters: `min(fm.file_id::text)` (casts every row) +//! vs `min(fm.file_id)::text` (one cast per cluster). Gate: +//! identical cluster rows (uuid byte order == canonical text order). +//! 5. Recluster persistence: F sequential `assign_person` UPDATEs vs +//! one `UPDATE … FROM unnest($1,$2)` batch. Gate: identical final +//! `person_id` column state. +//! +//! Run (needs Postgres; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_round11_queries +//! Tunables (env): BENCH_PASSES (200) + +use std::sync::Arc; +use std::time::Instant; + +use sqlx::{PgPool, Row, postgres::PgPoolOptions}; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn p50(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +async fn timed(passes: usize, mut f: F) -> (f64, R) +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let mut last = f().await; + let mut samples = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + last = f().await; + samples.push(t.elapsed().as_secs_f64() * 1e3); + } + (p50(samples), last) +} + +fn gate(name: &str, ok: bool) { + if ok { + println!(" gate[{name}]: OK"); + } else { + println!(" gate[{name}]: FAILED — DO NOT SHIP THIS SECTION"); + } +} + +struct Seed { + owner: Uuid, + drive: Uuid, + root: Uuid, +} + +async fn seed_base(pool: &PgPool, tag: &str) -> Seed { + // Idempotent sweep of leftovers from an aborted earlier run. + let _ = sqlx::query( + "DELETE FROM storage.files WHERE drive_id IN + (SELECT id FROM storage.drives WHERE default_for_user IN + (SELECT id FROM auth.users WHERE username = $1))", + ) + .bind(format!("bench_r11_{tag}")) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.folders WHERE lpath = $1::ltree") + .bind(format!("br11{tag}")) + .execute(pool) + .await; + let _ = sqlx::query( + "DELETE FROM storage.drives WHERE default_for_user IN + (SELECT id FROM auth.users WHERE username = $1)", + ) + .bind(format!("bench_r11_{tag}")) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE username = $1") + .bind(format!("bench_r11_{tag}")) + .execute(pool) + .await; + + let mut tx = pool.begin().await.expect("begin seed tx"); + let owner: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ($1, $2, 'user') RETURNING id", + ) + .bind(format!("bench_r11_{tag}")) + .bind(format!("bench_r11_{tag}@bench.invalid")) + .fetch_one(&mut *tx) + .await + .expect("seed owner"); + let drive: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, default_for_user, policies) + VALUES ('personal', $1, '{\"include_in_photo_index\": true}'::jsonb) RETURNING id", + ) + .bind(owner) + .fetch_one(&mut *tx) + .await + .expect("seed drive"); + let root: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('Personal', '/Personal', $2::ltree, $1) RETURNING id", + ) + .bind(drive) + .bind(format!("br11{tag}")) + .fetch_one(&mut *tx) + .await + .expect("seed root"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root) + .bind(drive) + .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, 'owner'::storage.grant_role, $1)", + ) + .bind(owner) + .bind(drive) + .execute(&mut *tx) + .await + .expect("seed owner grant"); + tx.commit().await.expect("commit seed tx"); + Seed { owner, drive, root } +} + +async fn cleanup_base(pool: &PgPool, s: &Seed) { + let _ = sqlx::query("DELETE FROM storage.role_grants WHERE subject_id = $1 OR granted_by = $1") + .bind(s.owner) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1") + .bind(s.drive) + .execute(pool) + .await; + let _ = sqlx::query("UPDATE storage.drives SET root_folder_id = NULL WHERE id = $1") + .bind(s.drive) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1") + .bind(s.root) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(s.drive) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(s.owner) + .execute(pool) + .await; +} + +// ─── §1 deferred upload registration ──────────────────────────────────────── + +const PLACEHOLDER: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +async fn deferred_before( + pool: &PgPool, + name: &str, + folder_id: Uuid, + caller: Uuid, +) -> (String, String, Uuid) { + // Q1: resolve_parent_drive + let drive_id: Uuid = + sqlx::query_scalar("SELECT drive_id FROM storage.folders WHERE id = $1::uuid") + .bind(folder_id.to_string()) + .fetch_optional(pool) + .await + .expect("q1") + .expect("parent exists"); + // Q2: INSERT + let row: (String, i64, i64) = sqlx::query_as( + r#" + INSERT INTO storage.files + (name, folder_id, drive_id, blob_hash, size, + mime_type, category_order, created_by, updated_by) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $8) + RETURNING id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(name) + .bind(folder_id.to_string()) + .bind(drive_id) + .bind(PLACEHOLDER) + .bind(4096i64) + .bind("application/octet-stream") + .bind(9999i16) + .bind(caller) + .fetch_one(pool) + .await + .expect("q2"); + // Q3: lookup_folder_path + let path: String = sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid") + .bind(folder_id.to_string()) + .fetch_optional(pool) + .await + .expect("q3") + .expect("parent exists"); + (row.0, path, drive_id) +} + +async fn deferred_after( + pool: &PgPool, + name: &str, + folder_id: Uuid, + caller: Uuid, +) -> Option<(String, String, Uuid)> { + let row: Option<(String, String, Uuid, i64, i64)> = sqlx::query_as( + r#" + WITH parent AS ( + SELECT id, drive_id, path FROM storage.folders WHERE id = $2::uuid + ) + INSERT INTO storage.files + (name, folder_id, drive_id, blob_hash, size, + mime_type, category_order, created_by, updated_by) + SELECT $1, parent.id, parent.drive_id, $3, $4, $5, $6, $7, $7 + FROM parent + RETURNING id::text, + (SELECT path FROM parent), + drive_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(name) + .bind(folder_id.to_string()) + .bind(PLACEHOLDER) + .bind(4096i64) + .bind("application/octet-stream") + .bind(9999i16) + .bind(caller) + .fetch_optional(pool) + .await + .expect("cte insert"); + row.map(|(id, path, drive, _, _)| (id, path, drive)) +} + +async fn section_deferred(pool: &PgPool, s: &Seed, passes: usize) { + println!(" §1 deferred upload registration (per uploaded file)"); + + // Gates: identical (path, drive); missing parent → 0 rows (not-found). + let b = deferred_before(pool, "gate-b.bin", s.root, s.owner).await; + let a = deferred_after(pool, "gate-a.bin", s.root, s.owner) + .await + .expect("row"); + gate("path+drive identical", b.1 == a.1 && b.2 == a.2); + let missing = deferred_after(pool, "gate-m.bin", Uuid::new_v4(), s.owner).await; + gate("missing parent → not-found", missing.is_none()); + let _ = sqlx::query("DELETE FROM storage.files WHERE blob_hash = $1") + .bind(PLACEHOLDER) + .execute(pool) + .await; + + let (ms_b, _) = timed(passes, || async { + let r = deferred_before(pool, "bench-b.bin", s.root, s.owner).await; + let _ = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid") + .bind(&r.0) + .execute(pool) + .await; + r.2 + }) + .await; + let (ms_a, _) = timed(passes, || async { + let r = deferred_after(pool, "bench-a.bin", s.root, s.owner) + .await + .unwrap(); + let _ = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid") + .bind(&r.0) + .execute(pool) + .await; + r.2 + }) + .await; + // Both arms pay the same cleanup DELETE; the delta is the 3-vs-1 shape. + println!(" BEFORE 3 round-trips p50 {ms_b:.3} ms (incl. cleanup DELETE)"); + println!(" AFTER 1 CTE insert p50 {ms_a:.3} ms (incl. cleanup DELETE)"); +} + +// ─── §2 calendar direct-grant cache ───────────────────────────────────────── + +async fn direct_grant_query(pool: &PgPool, subject: Uuid, cal: Uuid) -> bool { + sqlx::query_scalar::<_, i32>( + "SELECT 1 FROM storage.role_grants + WHERE subject_type = ANY($1) AND subject_id = ANY($2) + AND role = ANY($3::storage.grant_role[]) + AND resource_type = $4 AND resource_id = $5 + AND (expires_at IS NULL OR expires_at > NOW()) + LIMIT 1", + ) + .bind(vec!["user"]) + .bind(vec![subject]) + .bind(vec!["reader", "contributor", "manager", "owner"]) + .bind("calendar") + .bind(cal) + .fetch_optional(pool) + .await + .expect("grant query") + .is_some() +} + +async fn section_grant_cache(pool: &Arc, s: &Seed, passes: usize) { + println!(" §2 Calendar/AddressBook/Playlist authz check (per DAV request)"); + let cal = Uuid::new_v4(); + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'calendar', $2, 'owner'::storage.grant_role, $1)", + ) + .bind(s.owner) + .bind(cal) + .execute(pool.as_ref()) + .await + .expect("seed calendar grant"); + + let cache: moka::future::Cache<(Uuid, Uuid), bool> = moka::future::Cache::builder() + .max_capacity(100_000) + .time_to_live(std::time::Duration::from_secs(30)) + .build(); + + // Gates: identical verdict; revocation + invalidate flips the verdict. + let v_query = direct_grant_query(pool, s.owner, cal).await; + let v_cached = { + let pool2 = pool.clone(); + cache + .try_get_with((s.owner, cal), async move { + Ok::(direct_grant_query(&pool2, s.owner, cal).await) + }) + .await + .unwrap() + }; + gate("verdict identical", v_query == v_cached && v_query); + sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_type = 'calendar' AND resource_id = $1", + ) + .bind(cal) + .execute(pool.as_ref()) + .await + .expect("revoke"); + cache.invalidate_all(); + let v_after_revoke = { + let pool2 = pool.clone(); + cache + .try_get_with((s.owner, cal), async move { + Ok::(direct_grant_query(&pool2, s.owner, cal).await) + }) + .await + .unwrap() + }; + gate("revocation flips verdict", !v_after_revoke); + // Re-seed for the measurement. + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'calendar', $2, 'owner'::storage.grant_role, $1)", + ) + .bind(s.owner) + .bind(cal) + .execute(pool.as_ref()) + .await + .expect("re-seed"); + cache.invalidate_all(); + + let (ms_b, _) = timed(passes, || async { + direct_grant_query(pool, s.owner, cal).await + }) + .await; + let (ms_a, _) = timed(passes, || async { + let pool2 = pool.clone(); + cache + .try_get_with((s.owner, cal), async move { + Ok::(direct_grant_query(&pool2, s.owner, cal).await) + }) + .await + .unwrap() + }) + .await; + println!(" BEFORE role_grants query per check p50 {ms_b:.3} ms"); + println!(" AFTER moka hit p50 {ms_a:.4} ms"); + + sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_type = 'calendar' AND resource_id = $1", + ) + .bind(cal) + .execute(pool.as_ref()) + .await + .expect("cleanup grant"); +} + +// ─── §3 expand_user serial vs join ────────────────────────────────────────── + +async fn q_is_external(pool: &PgPool, user: Uuid) -> bool { + sqlx::query_scalar::<_, bool>("SELECT is_external FROM auth.users WHERE id = $1") + .bind(user) + .fetch_optional(pool) + .await + .expect("is_external") + .unwrap_or(true) +} + +async fn q_groups(pool: &PgPool, user: Uuid) -> Vec { + sqlx::query( + "WITH RECURSIVE user_groups AS ( + SELECT group_id + FROM auth.subject_group_members + WHERE member_user_id = $1 + UNION + SELECT m.group_id + FROM auth.subject_group_members m + JOIN user_groups ug ON m.member_group_id = ug.group_id + ) + SELECT group_id FROM user_groups", + ) + .bind(user) + .fetch_all(pool) + .await + .expect("groups CTE") + .iter() + .map(|r| r.get::("group_id")) + .collect() +} + +async fn section_expand(pool: &PgPool, s: &Seed, passes: usize) { + println!(" §3 expand_user cold miss (per user per TTL window)"); + + let b = { + let e = q_is_external(pool, s.owner).await; + let g = q_groups(pool, s.owner).await; + (e, g) + }; + let a = { + let (e, g) = tokio::join!(q_is_external(pool, s.owner), q_groups(pool, s.owner)); + (e, g) + }; + gate("expansion identical", b == a); + + let (ms_b, _) = timed(passes, || async { + let e = q_is_external(pool, s.owner).await; + let g = q_groups(pool, s.owner).await; + (e, g.len()) + }) + .await; + let (ms_a, _) = timed(passes, || async { + let (e, g) = tokio::join!(q_is_external(pool, s.owner), q_groups(pool, s.owner)); + (e, g.len()) + }) + .await; + println!(" BEFORE serial 2 queries p50 {ms_b:.3} ms"); + println!(" AFTER tokio::join! p50 {ms_a:.3} ms"); +} + +// ─── §4 geo clusters min cast ─────────────────────────────────────────────── + +async fn geo_query(pool: &PgPool, caller: Uuid, min_expr: &str) -> Vec<(i64, f64, f64, String)> { + sqlx::query_as(&format!( + r#" + SELECT count(*) AS n, + avg(fm.longitude) AS clng, + avg(fm.latitude) AS clat, + {min_expr} AS sample_id + FROM storage.file_metadata fm + JOIN storage.files fi ON fi.id = fm.file_id + WHERE fi.drive_id IN ( + SELECT d.id + FROM storage.drives d + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE ( + (g.subject_type = 'user' AND g.subject_id = $1) + OR (g.subject_type = 'group' AND g.subject_id IN + (SELECT storage.caller_group_ids($1))) + ) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND (d.policies->>'include_in_photo_index')::boolean = true + ) + AND NOT fi.is_trashed + AND fm.latitude IS NOT NULL + AND fm.longitude IS NOT NULL + AND fm.longitude BETWEEN $2 AND $3 + AND fm.latitude BETWEEN $4 AND $5 + GROUP BY round(fm.longitude / $6), round(fm.latitude / $6) + "# + )) + .bind(caller) + .bind(-10.0f64) + .bind(10.0f64) + .bind(35.0f64) + .bind(45.0f64) + .bind(0.5f64) + .fetch_all(pool) + .await + .expect("geo query") +} + +async fn section_geo(pool: &PgPool, s: &Seed, passes: usize) { + println!(" §4 Places geo clusters (per map viewport, 5k geotagged rows)"); + // Seed 5k geotagged photos across the viewport. + let mut tx = pool.begin().await.expect("begin geo seed"); + for chunk in 0..10 { + let ids: Vec = sqlx::query_scalar( + "INSERT INTO storage.files + (name, folder_id, drive_id, blob_hash, size, mime_type, category_order) + SELECT 'geo-' || $4 || '-' || g, $1, $2, $3, 1024, 'image/jpeg', 100 + FROM generate_series(1, 500) g + RETURNING id", + ) + .bind(s.root) + .bind(s.drive) + .bind(PLACEHOLDER) + .bind(chunk.to_string()) + .fetch_all(&mut *tx) + .await + .expect("seed geo files"); + sqlx::query( + "INSERT INTO storage.file_metadata (file_id, latitude, longitude) + SELECT u, 35.0 + (random() * 10.0), -10.0 + (random() * 20.0) + FROM unnest($1::uuid[]) u", + ) + .bind(&ids) + .execute(&mut *tx) + .await + .expect("seed geo meta"); + } + tx.commit().await.expect("commit geo seed"); + + let mut b = geo_query(pool, s.owner, "min(fm.file_id::text)").await; + let mut a = geo_query(pool, s.owner, "min(fm.file_id)::text").await; + b.sort_by(|x, y| x.3.cmp(&y.3)); + a.sort_by(|x, y| x.3.cmp(&y.3)); + gate("cluster rows identical", a == b); + + let (ms_b, _) = timed(passes.min(60), || async { + geo_query(pool, s.owner, "min(fm.file_id::text)") + .await + .len() + }) + .await; + let (ms_a, _) = timed(passes.min(60), || async { + geo_query(pool, s.owner, "min(fm.file_id)::text") + .await + .len() + }) + .await; + println!(" BEFORE min(file_id::text) p50 {ms_b:.3} ms"); + println!(" AFTER min(file_id)::text p50 {ms_a:.3} ms"); + + let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1 AND name LIKE 'geo-%'") + .bind(s.drive) + .execute(pool) + .await; +} + +// ─── §5 recluster assignment batch ────────────────────────────────────────── + +async fn section_recluster(pool: &PgPool, s: &Seed, passes: usize) { + println!(" §5 recluster face assignment (200-face library)"); + // One photo + 200 faces. + let file: Uuid = sqlx::query_scalar( + "INSERT INTO storage.files + (name, folder_id, drive_id, blob_hash, size, mime_type, category_order) + VALUES ('faces.jpg', $1, $2, $3, 1024, 'image/jpeg', 100) RETURNING id", + ) + .bind(s.root) + .bind(s.drive) + .bind(PLACEHOLDER) + .fetch_one(pool) + .await + .expect("seed face file"); + let face_ids: Vec = sqlx::query_scalar( + "INSERT INTO faces.faces (file_id, user_id, bbox, det_score, embedding) + SELECT $1, $2, ARRAY[0.1,0.1,0.2,0.2]::real[], 0.99, '\\x00'::bytea + FROM generate_series(1, 200) + RETURNING id", + ) + .bind(file) + .bind(s.owner) + .fetch_all(pool) + .await + .expect("seed faces"); + let person: Uuid = + sqlx::query_scalar("INSERT INTO faces.persons (user_id) VALUES ($1) RETURNING id") + .bind(s.owner) + .fetch_one(pool) + .await + .expect("seed person"); + + let assignments: Vec<(Uuid, Option)> = + face_ids.iter().map(|f| (*f, Some(person))).collect(); + + async fn reset(pool: &PgPool, ids: &[Uuid]) { + sqlx::query("UPDATE faces.faces SET person_id = NULL WHERE id = ANY($1)") + .bind(ids) + .execute(pool) + .await + .expect("reset"); + } + async fn state(pool: &PgPool, ids: &[Uuid]) -> Vec<(Uuid, Option)> { + let mut rows: Vec<(Uuid, Option)> = + sqlx::query_as("SELECT id, person_id FROM faces.faces WHERE id = ANY($1)") + .bind(ids) + .fetch_all(pool) + .await + .expect("state"); + rows.sort(); + rows + } + + // BEFORE: one UPDATE per face. + reset(pool, &face_ids).await; + for (f, p) in &assignments { + sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1") + .bind(f) + .bind(p) + .execute(pool) + .await + .expect("assign"); + } + let st_b = state(pool, &face_ids).await; + // AFTER: one UNNEST batch. + reset(pool, &face_ids).await; + let (fs, ps): (Vec, Vec>) = assignments.iter().cloned().unzip(); + sqlx::query( + "UPDATE faces.faces f SET person_id = u.pid + FROM (SELECT unnest($1::uuid[]) AS fid, unnest($2::uuid[]) AS pid) u + WHERE f.id = u.fid", + ) + .bind(&fs) + .bind(&ps) + .execute(pool) + .await + .expect("batch assign"); + let st_a = state(pool, &face_ids).await; + gate("final person_id state identical", st_a == st_b); + + let it = passes.min(30); + let (ms_b, _) = timed(it, || async { + reset(pool, &face_ids).await; + for (f, p) in &assignments { + sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1") + .bind(f) + .bind(p) + .execute(pool) + .await + .expect("assign"); + } + 0u32 + }) + .await; + let (ms_a, _) = timed(it, || async { + reset(pool, &face_ids).await; + let (fs, ps): (Vec, Vec>) = assignments.iter().cloned().unzip(); + sqlx::query( + "UPDATE faces.faces f SET person_id = u.pid + FROM (SELECT unnest($1::uuid[]) AS fid, unnest($2::uuid[]) AS pid) u + WHERE f.id = u.fid", + ) + .bind(&fs) + .bind(&ps) + .execute(pool) + .await + .expect("batch"); + 0u32 + }) + .await; + println!(" BEFORE 200 sequential UPDATEs p50 {ms_b:.3} ms (incl. reset)"); + println!(" AFTER 1 UNNEST batch p50 {ms_a:.3} ms (incl. reset)"); + + let _ = sqlx::query("DELETE FROM faces.persons WHERE id = $1") + .bind(person) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.files WHERE id = $1") + .bind(file) + .execute(pool) + .await; +} + +// ─── main ─────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + let _ = dotenvy::dotenv(); + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required (see .env)"); + let passes: usize = env_or("BENCH_PASSES", 200); + println!("bench_round11_queries — passes={passes}\n"); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(8) + .connect(&url) + .await + .expect("connect"), + ); + let seed = seed_base(&pool, "q").await; + + section_deferred(&pool, &seed, passes).await; + section_grant_cache(&pool, &seed, passes).await; + section_expand(&pool, &seed, passes).await; + section_geo(&pool, &seed, passes).await; + section_recluster(&pool, &seed, passes.min(30)).await; + + cleanup_base(&pool, &seed).await; + println!("\ndone"); +} diff --git a/examples/bench_row_path.rs b/examples/bench_row_path.rs index e2fbe273..a955673e 100644 --- a/examples/bench_row_path.rs +++ b/examples/bench_row_path.rs @@ -482,7 +482,7 @@ fn gate_file(name: &str, folder_path: Option<&str>) -> bool { ); match (b, a) { (Ok(b), Ok(a)) => { - let seg_a: Vec = a.storage_path().segments().to_vec(); + let seg_a: Vec = a.storage_path().segments().map(str::to_string).collect(); if b.name != a.name() || b.path_string != a.path_string() || b.storage_path.segments != seg_a @@ -539,7 +539,7 @@ fn gate_folder(name: &str, path: &str) -> bool { ); match (b, a) { (Ok(b), Ok(a)) => { - let seg_a: Vec = a.storage_path().segments().to_vec(); + let seg_a: Vec = a.storage_path().segments().map(str::to_string).collect(); if b.name != a.name() || b.path_string != a.path_string() || b.storage_path.segments != seg_a diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index d8be2591..164afb4e 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -119,6 +119,15 @@ onopen?: (entry: ResourceEntry) => void; /** Per-entry favorite star toggle. */ onfavorite?: (entry: ResourceEntry) => void; + /** + * Live favorite-id set for the star state. When provided, the star + * reads membership here instead of `entry.isFavorite`, so a toggle + * repaints one star instead of forcing the host to rebuild every + * entry object (the Recent page paid a full O(N) re-map + re-render + * per star click through its `favoriteIds.has()` inside the entry + * mapper — see benches/ROUND11.md). + */ + favoriteIds?: ReadonlySet | null; /** Selection changed (set of selected entry ids). */ onselectionchange?: (ids: Set) => void; actions?: Snippet<[ResourceEntry]>; @@ -156,6 +165,7 @@ onreload, onopen, onfavorite, + favoriteIds = null, onselectionchange, actions, toolbar, @@ -252,7 +262,22 @@ onselectionchange?.(selected); } } - const selectedEntries = $derived(items.filter((i) => selected.has(i.id))); + // Index rebuilt only when `items` changes; the projection below is then + // O(k · log k) in the selection size k instead of the old O(N) full-list + // `items.filter(...)` re-scan on every toggle (O(N²)-ish across a + // shift-range gesture once the batch toolbar was mounted). Item order is + // preserved via the index sort so the toolbar sees the same array the + // filter produced. + const itemIndexById = $derived(new Map(items.map((i, idx) => [i.id, idx]))); + const selectedEntries = $derived.by(() => { + const picked: { idx: number; item: ResourceEntry }[] = []; + for (const id of selected) { + const idx = itemIndexById.get(id); + if (idx !== undefined) picked.push({ idx, item: items[idx] }); + } + picked.sort((a, b) => a.idx - b.idx); + return picked.map((p) => p.item); + }); // Drop selection ids that are no longer present after a reload. $effect(() => { @@ -380,18 +405,19 @@ {#if onfavorite} + {@const starred = favoriteIds ? favoriteIds.has(entry.id) : !!entry.isFavorite} {/if} {#if actions} diff --git a/frontend/src/lib/components/round11.bench.test.ts b/frontend/src/lib/components/round11.bench.test.ts new file mode 100644 index 00000000..4eab0527 --- /dev/null +++ b/frontend/src/lib/components/round11.bench.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Benchmark gates for the round-11 SPA items (see benches/ROUND11.md): + * + * [1] `ResourceList.selectedEntries` re-filtered the ENTIRE items array on + * every selection change once the batch toolbar was mounted — and the + * favorites/recent hosts ignored the snippet param and recomputed their + * own `entries.filter(...)` shadow, so each toggle ran TWO full O(N) + * scans (O(N²)-ish across a shift-range gesture). The shipped shape + * derives an id→index Map (rebuilt only when `items` changes) and + * projects the selection in O(k · log k), preserving item order; hosts + * now consume the snippet param. + * + * [2] The Recent page mapper baked `favoriteIds.has(id)` into every entry, + * subscribing the whole O(N) map to the SvelteSet — one star click + * rebuilt all N entries and re-rendered every visible row. The shipped + * shape reads membership in the star widget via ResourceList's new + * `favoriteIds` prop, so the mapper no longer depends on the set. + * + * [3] The admin "time ago" >30-day fallback called `toLocaleDateString()` + * (a fresh Intl.DateTimeFormat per call) instead of the cached + * `dateTimeFormatFor` the rest of the app uses. + * + * All modeled as pure replicas of the derive bodies with instrumentation + * counters (the listDerives.bench.test.ts convention). + */ + +interface Entry { + id: string; + name: string; +} + +const buildItems = (n: number): Entry[] => + Array.from({ length: n }, (_, i) => ({ id: `it-${i}`, name: `Item ${i}` })); + +/** BEFORE — component derive + host shadow, each a full O(N) scan. */ +function selectedBefore( + items: Entry[], + selected: Set, + counter: { comparisons: number } +): { component: Entry[]; host: Entry[] } { + const component = items.filter((i) => { + counter.comparisons++; + return selected.has(i.id); + }); + const host = items.filter((i) => { + counter.comparisons++; + return selected.has(i.id); + }); + return { component, host }; +} + +/** AFTER — id→index Map projection, index rebuilt only on items change. */ +function makeAfterProjector(items: Entry[]) { + const indexById = new Map(items.map((i, idx) => [i.id, idx])); + return (selected: Set, counter: { comparisons: number }): Entry[] => { + const picked: { idx: number; item: Entry }[] = []; + for (const id of selected) { + counter.comparisons++; + const idx = indexById.get(id); + if (idx !== undefined) picked.push({ idx, item: items[idx] }); + } + picked.sort((a, b) => a.idx - b.idx); + return picked.map((p) => p.item); + }; +} + +describe('ResourceList selectedEntries projection (benchmark gate)', () => { + it('identical output (order + membership) and O(k) vs O(2N) comparisons per toggle', () => { + const N = 2000; + const items = buildItems(N); + const project = makeAfterProjector(items); + + // Model a 50-item shift-range selection built one id at a time, + // re-deriving after each toggle (what the reactive graph does). + const selected = new Set(); + const beforeCounter = { comparisons: 0 }; + const afterCounter = { comparisons: 0 }; + // Insert in REVERSE order so selection order ≠ item order — the + // order-preservation gate below must still hold. Each toggle + // re-derives both shapes (what the reactive graph does). + for (let i = 149; i >= 100; i--) { + selected.add(`it-${i}`); + selectedBefore(items, selected, beforeCounter); + project(selected, afterCounter); + } + // Stale ids (deleted rows) must be dropped by both shapes. + selected.add('it-ghost'); + const lastBefore = selectedBefore(items, selected, beforeCounter).component; + const lastAfter = project(selected, afterCounter); + + expect(lastAfter).toEqual(lastBefore); // same entries, same (item) order + // BEFORE: 2 scans × N per toggle. AFTER: k probes per toggle. + expect(beforeCounter.comparisons).toBe(51 * 2 * N); + expect(afterCounter.comparisons).toBeLessThan(51 * 51 + 1); + }); + + it('wall clock: 500-toggle sweep on a 5k list is faster with the projection', () => { + const N = 5000; + const items = buildItems(N); + const project = makeAfterProjector(items); + const selected = new Set(); + const nul = { comparisons: 0 }; + + const t0 = performance.now(); + for (let i = 0; i < 500; i++) { + selected.add(`it-${i}`); + selectedBefore(items, selected, nul); + } + const tBefore = performance.now() - t0; + + selected.clear(); + const t1 = performance.now(); + for (let i = 0; i < 500; i++) { + selected.add(`it-${i}`); + project(selected, nul); + } + const tAfter = performance.now() - t1; + + // Generous bound to keep CI stable; locally ~10-40x. + expect(tAfter).toBeLessThan(tBefore); + }); +}); + +// ─── [2] Recent favorite-star dependency ──────────────────────────────────── + +interface RawItem { + id: string; + name: string; +} + +/** BEFORE — mapper reads the favorite set: every toggle re-maps ALL rows. */ +function entriesBefore( + raw: RawItem[], + favoriteIds: Set, + counter: { mapperRows: number } +): { id: string; isFavorite: boolean }[] { + return raw.map((it) => { + counter.mapperRows++; + return { id: it.id, isFavorite: favoriteIds.has(it.id) }; + }); +} + +/** AFTER — mapper is set-independent; the star widget reads membership. */ +function entriesAfter(raw: RawItem[], counter: { mapperRows: number }): { id: string }[] { + return raw.map((it) => { + counter.mapperRows++; + return { id: it.id }; + }); +} +function starStateAfter(favoriteIds: Set, id: string): boolean { + return favoriteIds.has(id); +} + +describe('Recent favorite-star fine-grained dependency (benchmark gate)', () => { + it('a star toggle re-maps 0 rows (was N) and renders the same star states', () => { + const N = 400; + const raw: RawItem[] = Array.from({ length: N }, (_, i) => ({ + id: `r-${i}`, + name: `File ${i}` + })); + const favoriteIds = new Set(['r-3']); + + const beforeCounter = { mapperRows: 0 }; + const afterCounter = { mapperRows: 0 }; + + // Initial render: both shapes map all rows once. + let entriesB = entriesBefore(raw, favoriteIds, beforeCounter); + const entriesA = entriesAfter(raw, afterCounter); + expect(beforeCounter.mapperRows).toBe(N); + expect(afterCounter.mapperRows).toBe(N); + + // 10 star toggles. BEFORE: the mapper depends on the set → full + // re-map each time. AFTER: the mapper doesn't run at all. + for (let k = 0; k < 10; k++) { + const id = `r-${k * 7}`; + if (favoriteIds.has(id)) favoriteIds.delete(id); + else favoriteIds.add(id); + entriesB = entriesBefore(raw, favoriteIds, beforeCounter); // reactive re-run + // AFTER: no mapper re-run; only the affected star re-reads. + starStateAfter(favoriteIds, id); + } + + expect(beforeCounter.mapperRows).toBe(N + 10 * N); + expect(afterCounter.mapperRows).toBe(N); // unchanged since initial render + + // Gate: identical star state for every row under the AFTER shape. + for (let i = 0; i < N; i++) { + expect(starStateAfter(favoriteIds, entriesA[i].id)).toBe(entriesB[i].isFavorite); + } + }); +}); + +// ─── [3] admin timeAgo date fallback ──────────────────────────────────────── + +describe('admin timeAgo >30d fallback formatter cache (benchmark gate)', () => { + it('cached formatter output is identical to toLocaleDateString()', async () => { + const { dateTimeFormatFor } = await import('../utils/display'); + const dates = [ + new Date('2025-01-15T10:30:00Z'), + new Date('2024-12-31T23:59:59Z'), + new Date('2020-06-01T00:00:00Z'), + new Date('1999-02-28T12:00:00Z') + ]; + for (const d of dates) { + expect(dateTimeFormatFor(undefined).format(d)).toBe(d.toLocaleDateString()); + } + }); + + it('1000 formats construct ≤1 Intl.DateTimeFormat (was 1000)', async () => { + const { dateTimeFormatFor } = await import('../utils/display'); + const RealDTF = Intl.DateTimeFormat; + let constructed = 0; + // Count constructions through both paths. + const Counting = new Proxy(RealDTF, { + construct(target, args: [string?, Intl.DateTimeFormatOptions?]) { + constructed++; + return new target(...args); + } + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Intl as any).DateTimeFormat = Counting; + try { + const d = new Date('2020-06-01T00:00:00Z'); + constructed = 0; + for (let i = 0; i < 1000; i++) { + d.toLocaleDateString(); + } + // jsdom implements toLocaleDateString via Intl internally in some + // versions; count only if observable. The load-bearing assertion + // is the cached path below. + const beforeConstructed = constructed; + + constructed = 0; + for (let i = 0; i < 1000; i++) { + dateTimeFormatFor(undefined).format(d); + } + expect(constructed).toBeLessThanOrEqual(1); + // When the environment exposes per-call constructions, require + // the cached path to be strictly cheaper. + if (beforeConstructed > 1) { + expect(constructed).toBeLessThan(beforeConstructed); + } + } finally { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Intl as any).DateTimeFormat = RealDTF; + } + }); +}); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 2deda19b..35938abb 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,5 +1,6 @@