From 955f4a7b9f5cf0255445c7cc9ee580e8810d19fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:30:22 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20round=2016=20=E2=80=94=20shares-lane=20?= =?UTF-8?q?&=20contextMap=20incremental=20builders,=20folder/href/disposit?= =?UTF-8?q?ion/preview=20alloc=20cuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the route-level half of the O(N²/page) grouped-listing class ROUND15 fixed inside ResourceList, plus a backend CPU/alloc micro-pack. Every change is benchmark-gated with a hard rollback rule; no PostgreSQL needed for any arm (benches/ROUND16.md). Frontend (vitest): - F1 "My shares" lanes: the `lanes` $derived.by re-bucketed the whole accumulated grant list on every page and every grant edit. SharedLanesBuilder re-emits only the fresh page (fan-out + first-appearance header), reusing untouched lanes' array refs. 25.5x fewer emit calls, 8.8x wall, O(N²/page)→O(N). - F2 contextMap (trash/recent/favorites/shared-with-me): each rebuilt a fresh N-key Map, re-hashing every accumulated id, per page. primeContextPage holds a persistent SvelteMap primed per page (the shipped favoriteIds shape). 25.5x fewer entry calls, 7.2x wall. - Extracted the shared O(1) append test (isAppendExtension); F1's gate re-covers it. Backend (counting-allocator): - M1 folder display constants Arc::from -> intern_display (3 sites): 3 -> 0 allocs/row. - M2 build_content_disposition (every download + Range seek): 3 -> 1 alloc, 6x, 2.67x wall. - M3 nc_href (every NC PROPFIND/REPORT href): Vec+join+format -> one pre-sized buffer, keeping urlencoding::encode (byte-identical). 38 -> 27 allocs/op. - M4 NC preview fileId: collect-then-parse -> borrow-slice parse. 4 -> 0 allocs. Gates: sharedLanes/listContext.bench.test.ts, examples/bench_round16_micro.rs (GATE PASS all sections). Frontend: vitest 331 pass, svelte-check clean. Backend: clippy -D warnings clean, 524 lib tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0193NjactJVqfU32gxeJDj8m --- Cargo.toml | 11 + benches/ROUND16.md | 178 ++++++++ examples/bench_round16_micro.rs | 389 ++++++++++++++++++ frontend/src/lib/utils/appendExtension.ts | 21 + .../src/lib/utils/listContext.bench.test.ts | 141 +++++++ frontend/src/lib/utils/listContext.ts | 40 ++ frontend/src/lib/utils/resourceSections.ts | 13 +- .../src/lib/utils/sharedLanes.bench.test.ts | 257 ++++++++++++ frontend/src/lib/utils/sharedLanes.ts | 188 +++++++++ frontend/src/routes/favorites/+page.svelte | 16 +- frontend/src/routes/recent/+page.svelte | 19 +- .../src/routes/shared-with-me/+page.svelte | 20 +- frontend/src/routes/shared/+page.svelte | 92 +++-- frontend/src/routes/trash/+page.svelte | 24 +- src/application/services/trash_service.rs | 6 +- .../services/path_resolver_service.rs | 6 +- src/interfaces/api/handlers/file_handler.rs | 39 +- src/interfaces/nextcloud/preview_handler.rs | 11 +- src/interfaces/nextcloud/report_handler.rs | 8 +- src/interfaces/nextcloud/webdav_handler.rs | 32 +- 20 files changed, 1388 insertions(+), 123 deletions(-) create mode 100644 benches/ROUND16.md create mode 100644 examples/bench_round16_micro.rs create mode 100644 frontend/src/lib/utils/appendExtension.ts create mode 100644 frontend/src/lib/utils/listContext.bench.test.ts create mode 100644 frontend/src/lib/utils/listContext.ts create mode 100644 frontend/src/lib/utils/sharedLanes.bench.test.ts create mode 100644 frontend/src/lib/utils/sharedLanes.ts diff --git a/Cargo.toml b/Cargo.toml index cae6783b..30ebaa37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,17 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-16 battery ──────────────────────────────────────────────────────────── + +# Round-16 CPU/alloc micro-pack — folder display constants `Arc::from` → interned +# clone (3 sites), `build_content_disposition` 3→1 alloc (every download + Range +# seek), `nc_href` Vec+join → single pre-sized buffer (every NC PROPFIND/REPORT +# href), NC preview `fileId` collect-then-parse → borrowed-slice parse. No Postgres. +[[example]] +name = "bench_round16_micro" +path = "examples/bench_round16_micro.rs" +required-features = ["bench"] + # Round-15 battery ──────────────────────────────────────────────────────────── # Round-15 CPU/alloc micro-pack — exif Make/Model in-place trim (drop the diff --git a/benches/ROUND16.md b/benches/ROUND16.md new file mode 100644 index 00000000..74597a86 --- /dev/null +++ b/benches/ROUND16.md @@ -0,0 +1,178 @@ +# Round 16 — shares-lane & contextMap incremental builders, folder/href/disposition/preview alloc cuts + +Benchmark-gated, same rule as ROUND2–15: every change ships with a +BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't +beat its BEFORE is rolled back (never applied). The roll-back rule is encoded +directly into each harness as a `GATE FAIL … rollback` non-zero exit (Rust) or +a threshold `expect()` (frontend), so a regression fails CI rather than +shipping. + +This round finishes the **route-level half** of the O(N²/page) grouped-listing +class ROUND15 §F1 fixed *inside* `ResourceList` — the two remaining producers +that feed it (the "My shares" `lanes` tree and the trash/recent/favorites/ +shared-with-me `contextMap`) — and lands a backend CPU/alloc micro-pack of four +per-request allocation cuts surfaced by a fresh hot-path audit. + +Measured on 4 cores / 15 GiB, **no PostgreSQL needed for any Round-16 arm** +(frontend: Node 22 / vitest; backend: release counting-allocator examples). +Reproduce any row with the command in its section. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| F1 | "My shares" (`shared/+page.svelte`) `lanes` `$derived.by` re-bucketed the WHOLE accumulated grant list on every infinite-scroll page (and every grant edit); `SharedLanesBuilder` re-emits only the fresh page and reuses each untouched lane's array reference | 50×50 (2 500-item) drain | **63 750 → 2 500 `emit` calls (25.5×)** · **8.8× wall** · O(N²/page) → O(N) | +| F2 | trash / recent / favorites / shared-with-me rebuilt a fresh `Map` (hashing every accumulated id) as `contextMap = $derived(new Map(raw.map(…)))` every page; `primeContextPage` holds one persistent `SvelteMap` and sets only the fresh page's entries (mirrors `favoriteIds`, ROUND14 §F2) | 50×50 drain, 4 routes | **63 750 → 2 500 `entry` calls (25.5×)** · **7.2× wall** · O(N²/page) → O(N) | +| M1 | folder display constants — the trash-listing / NC-search-REPORT / path-resolver folder branch built `Arc::::from("fas fa-folder")` (+ 2 more): 3 heap allocs/row where the sibling file branch already used the interned `Arc` clone | 3 fields/folder row | **3.00 → 0.00 allocs/op**, 1.14× wall | +| M2 | `build_content_disposition` — every download + Range seek built an `encoded` String, an `ascii_safe` String, and the `format!` result (3 allocs); fast-path all-attr-char names + single in-place buffer do it in 1 | 5 names/op | **30.00 → 5.00 allocs/op (6×)**, 2.67× wall | +| M3 | `nc_href` — every NC PROPFIND/REPORT href allocated a per-segment `Vec`, a joined String and the `format!`; one pre-sized buffer keeps `urlencoding::encode` (identical bytes) | 5 hrefs/op | **38.00 → 27.00 allocs/op**, 1.44× wall | +| M4 | NC preview `fileId` — the handler `collect()`ed the digit prefix into a String only to reparse it to `i64`; parse the borrowed prefix slice instead | 5 ids/op | **4.00 → 0.00 allocs/op**, 2.51× wall | + +## [F1] "My shares" — incremental lanes builder + +``` +cd frontend && npx vitest run src/lib/utils/sharedLanes.bench.test.ts +``` + +The shares page pages its outgoing grants in via infinite scroll +(`raw = [...raw, ...page.items]`), and the `lanes` `$derived.by` re-bucketed the +whole accumulated (kind-filtered) list on every page — allocating a fresh lane +object and a fresh `rows` array for *every* lane each time — Σ ≈ O(N²/page) +`emit` calls across a drain. It also re-fired on every grant edit (role/expiry/ +password), each of which reassigns `raw`, re-bucketing the entire list for a +one-row change. + +`SharedLanesBuilder` (extracted to `$lib/utils/sharedLanes`, off the Svelte +reactive graph so it's unit/benchmark-testable) is the F1-flagship pattern +generalized for the lanes shape, which differs from `ResourceList`'s sections +in two ways: **fan-out** (one grant item contributes rows to *many* lanes in the +"shared with" group-by) and a **header captured at first appearance** (vs a +label recomputed each sync). On an append it re-emits only the fresh page and +hands back the same `rows` array reference for every untouched lane, emitting a +fresh array only for lanes the page actually grew. Any non-append (group-by +switch, grant edit, kind-filter toggle) falls back to a full rebuild, so the +output is always deep-equal to the pure `buildLanes` reference — including the +non-contiguous "shared with" group-by, where a page sprays rows across +already-emitted subject lanes (the same non-monotonic case F1 handled for trash +grouped by drive). The O(1) append test is shared with `resourceSections` via +`isAppendExtension` (extracted this round, re-validated by F1's own gate). + +50×50 (2 500-item) drain: **63 750 → 2 500 `emit` calls (25.5× fewer), 8.8× +wall**. Gates: (1) equivalence — deep-equal to `buildLanes` at *every* page for +both the by-files (contiguous) and by-subject (non-contiguous fan-out) +group-bys; (2) reference stability — untouched lanes keep their exact array +reference across an append while a grown lane gets a fresh one; (3) correct +fallback on group-by switch, grant edit and kind-filter toggle; (4) perf — the +deterministic O(N) `emit`-call count, plus a best-of-3 wall ≥3×. + +## [F2] Grouped routes — incremental `contextMap` + +``` +cd frontend && npx vitest run src/lib/utils/listContext.bench.test.ts +``` + +`/trash`, `/recent`, `/favorites` and `/shared-with-me` each fed `ResourceList` +a per-item `contextMap` (`id → ItemContext`, carrying the envelope's date / +owner / drive fields the group-by and row render read) built as +`$derived(new Map(raw.map((it) => [id, ctx])))` — a brand-new Map re-hashing +every accumulated id on **every** infinite-scroll page. O(N) per page ⇒ Σ +O(N²/page) across a drain, and a fresh instance each page invalidated every +reader. ROUND15 §F1 fixed the `sections` half *inside* `ResourceList`; this is +the route-level projection that feeds it, flagged on ROUND14's deferred list and +never landed. + +`primeContextPage` (`$lib/utils/listContext`) applies the shipped `favoriteIds` +shape (ROUND14 §F2, a persistent `SvelteSet` primed per page): each route holds +one persistent `SvelteMap` for the component's lifetime and, in `load()`, clears +it on a reset and sets only the freshly-fetched page's entries — O(page) per +page, O(N) across the drain, one stable instance. The map only ever needs to be +a superset of the displayed ids (rows removed by a delete aren't rendered, so +their stale entries are never read), and every id entering `raw` comes through a +`load()` page, so the map always covers what's on screen. `shared-with-me` +passes a drive-skipping entry (drives never reach the row UI), so its map +matches the displayed `fileFolderGrants` exactly. + +50×50 drain: **63 750 → 2 500 `entry` calls (25.5× fewer), 7.2× wall**. Gates: +(1) equivalence — the primed map is deep-equal to a full `new Map(cumulative.map(…))` +rebuild at every page, including skipped drives and the reset path; (2) perf — +the deterministic O(N) entry-call count, plus a best-of-3 wall ≥3×. + +## [M1]–[M4] Backend CPU/alloc micro-pack + +``` +cargo run --release --features bench --example bench_round16_micro +``` + +Counting-allocator micro-bench; each section is BEFORE (verbatim replica of the +shipped-before shape) vs AFTER (the shipped function itself where reachable — +`intern_display`, `nc_href` — else a verbatim replica of the shipped-after +shape), with a byte/-value equivalence gate and a `GATE FAIL … rollback` exit. + +- **[M1] Folder display constants → interned clone.** The trash-listing + (`trash_service.rs`), NC-search-REPORT (`report_handler.rs`) and path-resolver + (`path_resolver_service.rs`) folder branches each built + `Arc::::from("fas fa-folder")` + `"folder-icon"` + `"Folder"` — three + heap allocations + memcpys per folder row — although all three literals are in + the `DISPLAY_INTERN` closed set and the **file branch of the very same + function** already used `intern_display` (a lookup + refcount bump, 0 allocs). + ROUND11 interned the file classifiers on these paths but missed the folder + constants. Per trashed / searched / resolved folder row: **3.00 → 0.00 + allocs/op, 1.14× wall**. +- **[M2] `build_content_disposition` 3 → 1 alloc.** Called on every download and + every Range seek (media/PDF scrubbing pays it per seek), it built a + percent-`encoded` String, an `ascii_safe` filtered String, and the `format!` + result — 3 allocations. The shipped code fast-paths an all-attr-char name + (`filename` and `filename*` are the name verbatim → one `format!`) and, for + names needing encoding, writes the ASCII fallback and percent-encoded form + into a single pre-sized buffer. Byte-identical across ASCII / spaced / unicode + / quote+backslash names: **30.00 → 5.00 allocs/op (6×), 2.67× wall** (5 + names/op, a fast/slow mix). +- **[M3] `nc_href` Vec+join → single buffer.** Every NC PROPFIND/REPORT href + allocated a per-segment `Vec`, a joined String and the `format!` result; + the native WebDAV side already fixed this exact shape (`encode_uri_path`). The + shipped code writes the prefix, user and each encoded segment straight into one + pre-sized buffer, keeping `urlencoding::encode` so the emitted bytes are + unchanged (incl. root trailing slash and internal `//`): **38.00 → 27.00 + allocs/op, 1.44× wall** (5 hrefs/op — the Vec + join + format drop; the + per-segment encode Cows, unavoidable, remain). +- **[M4] NC preview `fileId` borrow-slice parse.** The preview handler + `collect()`ed the leading digit run into a String only to reparse it to `i64`; + the shipped code finds the digit-prefix length and parses the borrowed slice — + 0 allocations. Per NC thumbnail request (a gallery fires one per tile): **4.00 + → 0.00 allocs/op, 2.51× wall** (5 ids/op). + +## Not shipped — deferred to a later round + +Surfaced by the Round-16 audit but not landed (each wants its own decision, +Postgres fixture, or a larger change): + +- **Backend query-shape (needs Postgres):** carried forward from ROUND15 — + `music_storage_adapter::list_public_playlists` 1 + N `COUNT(*)` fold; contact + REST listings over-fetching the multi-KB `vcard` TEXT (wants a *lite* row + mapper). +- **Backend CPU/alloc (no Postgres, next micro-pack):** the two WebDAV PROPFIND + surfaces still quote `d:getetag` into a fresh String per row and `format!` the + per-row href per child (the CalDAV §A6 reused-buffer treatment never reached + them); `delta_upload_service` → `hash_chunk_sequence` clones every chunk hash a + second time (`.iter().cloned()` on an already-owned Vec — change the signature + to take it by value); `contact_to_vcard` seeds from a 27-byte String and + `.to_uppercase()`-allocates each TYPE token. +- **Frontend (vitest-benchmarkable):** `VirtualRows.offsets` prefix-sum is + rebuilt in full on every photos-timeline page (residual O(N²) on the hottest + scroll surface — an incremental extend needs care to keep the downstream + `$derived` reference-invalidation correct); the dotfile filter and + `ResourceList.itemIndexById` re-scan the whole accumulated list per page (both + conditional — hide-dotfiles on / an active selection — hence lower priority). + +## Environment / methodology + +- `cd frontend && npx vitest run src/lib/utils/sharedLanes.bench.test.ts` + and `… listContext.bench.test.ts` — Node 22 / vitest, no Postgres. Wall gates + take the best-of-3 (min) per arm to shrug off scheduler/GC noise under a + saturated runner (round14 §F1 pattern); the deterministic O(N) call-count is + the primary rollback gate. +- `cargo run --release --features bench --example bench_round16_micro` + — counting allocator, no Postgres (`BENCH_ITERS`). +- Roll-back rule encoded per harness: the Rust example `std::process::exit(1)` + with `GATE FAIL … rollback` if an AFTER arm fails to reduce allocations; the + vitest gates `expect()` the O(N) call count and the ≥3× wall. diff --git a/examples/bench_round16_micro.rs b/examples/bench_round16_micro.rs new file mode 100644 index 00000000..0f1853d5 --- /dev/null +++ b/examples/bench_round16_micro.rs @@ -0,0 +1,389 @@ +//! Round-16 CPU/alloc micro-pack (no Postgres). +//! +//! Each section is BEFORE (verbatim replica of the shipped-before shape) vs +//! AFTER (the shipped function itself where it is reachable, else a verbatim +//! replica of the shipped-after shape), with a byte/-value equivalence gate and +//! a `GATE FAIL … rollback` check that exits non-zero if the AFTER arm fails to +//! reduce allocations — the round's roll-back rule encoded into the benchmark. +//! +//! [M1] Folder display constants — the trash-listing / NC-search-REPORT / +//! path-resolver folder branch built `Arc::::from("fas fa-folder")` +//! (+ "folder-icon" + "Folder"): 3 heap allocs/row. All three are in the +//! `DISPLAY_INTERN` closed set, so `intern_display` returns an `Arc` +//! clone (refcount bump, 0 allocs) — the sibling file branch already did. +//! [M2] `build_content_disposition` — every download and every Range seek +//! built an `encoded` String, an `ascii_safe` String, and the `format!` +//! result: 3 allocs. The shipped fast path (all-attr-char name) and the +//! single in-place buffer (slow path) do it in 1. +//! [M3] `nc_href` — every NC PROPFIND/REPORT href allocated a per-segment +//! `Vec`, a joined String and the `format!` result. The shipped +//! single pre-sized buffer keeps `urlencoding::encode` (identical bytes) +//! and drops the Vec + join + format. +//! [M4] NC preview `fileId` — the handler `collect()`ed the digit prefix into +//! a String only to reparse it to `i64`. The shipped code parses the +//! borrowed digit-prefix slice — 0 allocs. +//! +//! Run: +//! cargo run --release --features bench --example bench_round16_micro +//! Tunables (env): BENCH_ITERS (200000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use oxicloud::application::dtos::display_helpers::intern_display; +use oxicloud::interfaces::nextcloud::webdav_handler::nc_href; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Measured { + wall_ns_per_op: f64, + allocs_per_op: f64, +} + +fn measure(iters: usize, mut f: F) -> Measured { + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let wall = t.elapsed().as_nanos() as f64 / iters as f64; + let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64; + Measured { + wall_ns_per_op: wall, + allocs_per_op: allocs, + } +} + +fn print_row(label: &str, m: &Measured) { + println!( + "| {:<44} | {:>12.1} | {:>10.2} |", + label, m.wall_ns_per_op, m.allocs_per_op + ); +} + +fn header_footer(name: &str, before: &Measured, after: &Measured) { + println!("| arm | ns/op | allocs/op |"); + print_row(&format!("BEFORE {name}"), before); + print_row(&format!("AFTER {name}"), after); + println!( + "# {:.2}x wall, {:.2} fewer allocs/op", + before.wall_ns_per_op / after.wall_ns_per_op, + before.allocs_per_op - after.allocs_per_op + ); +} + +fn gate_allocs(tag: &str, before: &Measured, after: &Measured) { + if after.allocs_per_op >= before.allocs_per_op { + eprintln!("GATE FAIL [{tag}]: AFTER did not reduce allocations — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M1] Folder display constants — 3 `Arc::from` allocs vs 0 (interned clone) +// ──────────────────────────────────────────────────────────────────────────── + +fn section_intern() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + const CONSTS: [&str; 3] = ["fas fa-folder", "folder-icon", "Folder"]; + + // Gate: the interned Arc carries identical bytes to `Arc::from`. + for s in CONSTS { + assert_eq!( + &*intern_display(s), + &*Arc::::from(s), + "intern differs for {s:?}" + ); + } + + // BEFORE: the folder branch's three `Arc::::from(literal)` — 3 allocs. + let before = measure(iters, || { + for s in CONSTS { + black_box(Arc::::from(black_box(s))); + } + }); + // AFTER: the shipped `intern_display` — closed-set lookup + refcount bump. + let after = measure(iters, || { + for s in CONSTS { + black_box(intern_display(black_box(s))); + } + }); + + println!("\n## [M1] folder display constants (3 fields/row)"); + header_footer("folder Arc::from → intern", &before, &after); + gate_allocs("M1", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M2] build_content_disposition — 3 allocs vs 1 +// ──────────────────────────────────────────────────────────────────────────── + +const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'!') + .remove(b'#') + .remove(b'$') + .remove(b'&') + .remove(b'+') + .remove(b'-') + .remove(b'.') + .remove(b'^') + .remove(b'_') + .remove(b'`') + .remove(b'|') + .remove(b'~'); + +fn disposition_of(mime: &str, force_inline: bool) -> &'static str { + if force_inline + || mime.starts_with("image/") + || mime == "application/pdf" + || mime.starts_with("video/") + || mime.starts_with("audio/") + { + "inline" + } else { + "attachment" + } +} + +/// BEFORE: verbatim replica of the shipped-before body — three allocations. +fn cd_before(name: &str, mime: &str, force_inline: bool) -> String { + let disposition = disposition_of(mime, force_inline); + let encoded = utf8_percent_encode(name, RFC5987_SET).to_string(); + let ascii_safe: String = name + .chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .map(|c| match c { + '"' | '\\' => '_', + _ => c, + }) + .collect(); + format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}") +} + +/// AFTER: verbatim replica of the shipped `build_content_disposition`. +fn cd_after(name: &str, mime: &str, force_inline: bool) -> String { + let disposition = disposition_of(mime, force_inline); + let all_attr_char = name.bytes().all(|b| { + b.is_ascii_alphanumeric() + || matches!( + b, + b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' + ) + }); + if all_attr_char { + return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}"); + } + let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32); + out.push_str(disposition); + out.push_str("; filename=\""); + for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') { + out.push(match c { + '"' | '\\' => '_', + _ => c, + }); + } + out.push_str("\"; filename*=UTF-8''"); + for chunk in utf8_percent_encode(name, RFC5987_SET) { + out.push_str(chunk); + } + out +} + +fn section_content_disposition() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + // Fast-path (all-attr-char) and slow-path (space / unicode / quote+backslash) + // names, inline and attachment. + let samples: &[(&str, &str, bool)] = &[ + ("report.pdf", "application/pdf", false), + ("photo.jpg", "image/jpeg", false), + ("My Holiday Photo.png", "image/png", false), + ("résumé final.docx", "application/octet-stream", false), + ("weird\"na\\me.txt", "text/plain", false), + ]; + + // Gate: byte-identical output to the old chain across every shape. + for &(n, m, f) in samples { + assert_eq!( + cd_before(n, m, f), + cd_after(n, m, f), + "content-disposition differs for {n:?}" + ); + } + + let before = measure(iters, || { + for &(n, m, f) in samples { + black_box(cd_before(black_box(n), m, f)); + } + }); + let after = measure(iters, || { + for &(n, m, f) in samples { + black_box(cd_after(black_box(n), m, f)); + } + }); + + println!( + "\n## [M2] build_content_disposition ({} names/op)", + samples.len() + ); + header_footer("content-disposition", &before, &after); + gate_allocs("M2", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M3] nc_href — Vec + join + format! vs one pre-sized buffer +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE: verbatim replica of the shipped-before `nc_href`. +fn nc_href_before(username: &str, subpath: &str) -> String { + let subpath = subpath.trim_matches('/'); + let encoded_user = urlencoding::encode(username); + if subpath.is_empty() { + format!("/remote.php/dav/files/{}/", encoded_user) + } else { + let encoded_segments: Vec<_> = subpath + .split('/') + .map(|seg| urlencoding::encode(seg)) + .collect(); + format!( + "/remote.php/dav/files/{}/{}", + encoded_user, + encoded_segments.join("/") + ) + } +} + +fn section_nc_href() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let samples: &[(&str, &str)] = &[ + ("alice", ""), + ("alice", "Documents/report.pdf"), + ("alice", "Photos/2026/My Holiday.jpg"), + ("bob smith", "Résumés/final draft.docx"), + ("carol", "a/deeply/nested/folder/tree/file.txt"), + ]; + + // Gate: the shipped `nc_href` is byte-identical to the old shape. + for &(u, sp) in samples { + assert_eq!( + nc_href_before(u, sp), + nc_href(u, sp), + "nc_href differs for {u:?}/{sp:?}" + ); + } + + let before = measure(iters, || { + for &(u, sp) in samples { + black_box(nc_href_before(black_box(u), black_box(sp))); + } + }); + let after = measure(iters, || { + for &(u, sp) in samples { + black_box(nc_href(black_box(u), black_box(sp))); + } + }); + + println!("\n## [M3] nc_href ({} hrefs/op)", samples.len()); + header_footer("nc_href", &before, &after); + gate_allocs("M3", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M4] NC preview fileId — collect-into-String-then-parse vs borrow-slice parse +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE: verbatim replica — allocate the digit prefix, then reparse it. +fn parse_before(file_id: &str) -> Result { + let numeric_part: String = file_id.chars().take_while(|c| c.is_ascii_digit()).collect(); + numeric_part.parse().map_err(|_| ()) +} + +/// AFTER: verbatim replica of the shipped code — parse the borrowed prefix. +fn parse_after(file_id: &str) -> Result { + let end = file_id + .as_bytes() + .iter() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(file_id.len()); + file_id[..end].parse().map_err(|_| ()) +} + +fn section_preview_parse() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + // The NC app appends an instance suffix; plus all-digit, non-digit and empty. + let samples = ["00000326ocnca", "123456789", "42abc", "notanid", ""]; + + // Gate: identical parse outcome across every shape. + for s in samples { + assert_eq!( + parse_before(s), + parse_after(s), + "preview parse differs for {s:?}" + ); + } + + let before = measure(iters, || { + for s in samples { + let _ = black_box(parse_before(black_box(s))); + } + }); + let after = measure(iters, || { + for s in samples { + let _ = black_box(parse_after(black_box(s))); + } + }); + + println!( + "\n## [M4] NC preview fileId parse ({} ids/op)", + samples.len() + ); + header_footer("preview fileId parse", &before, &after); + gate_allocs("M4", &before, &after); +} + +fn main() { + println!("#################################################################"); + println!("# Round-16 CPU/alloc micro-pack"); + println!("#################################################################"); + + section_intern(); + section_content_disposition(); + section_nc_href(); + section_preview_parse(); + + println!("\nGATE PASS (all sections)"); +} diff --git a/frontend/src/lib/utils/appendExtension.ts b/frontend/src/lib/utils/appendExtension.ts new file mode 100644 index 00000000..5f789e45 --- /dev/null +++ b/frontend/src/lib/utils/appendExtension.ts @@ -0,0 +1,21 @@ +/** + * O(1) append detection shared by the incremental grouped-list builders + * (`resourceSections`'s `ResourceSectionsBuilder`, `sharedLanes`'s + * `SharedLanesBuilder`): true iff `next` is a strict prefix extension of + * `prev` — strictly longer, and sharing prev's boundary element by identity. + * + * Both builders use it to choose between their O(N) incremental `extend` and a + * full rebuild. The accumulated lists they guard are only ever mutated by + * appending a page (infinite scroll: `raw = [...raw, ...page]`) or replaced by + * a filtered copy that preserves element identity — so a matching boundary + * object is a sound witness that only fresh items were appended. Any other + * change (deletion, filter toggle, reorder) fails the boundary check and falls + * back to a rebuild, keeping the output byte-for-byte equal to a full pass. + */ +export function isAppendExtension(prev: readonly T[], next: readonly T[]): boolean { + if (next.length <= prev.length) return false; + // Prefix identity via the boundary object — O(1). If the element that used + // to be last is still at that index, the prefix was untouched and next just + // grew at the tail. + return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1]; +} diff --git a/frontend/src/lib/utils/listContext.bench.test.ts b/frontend/src/lib/utils/listContext.bench.test.ts new file mode 100644 index 00000000..47d6df84 --- /dev/null +++ b/frontend/src/lib/utils/listContext.bench.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { primeContextPage } from './listContext'; + +/** + * Benchmark gate for the incremental route-level `contextMap` maintenance + * (primeContextPage) that replaced `contextMap = $derived(new Map(raw.map(...)))` + * on the trash / recent / favorites / shared-with-me routes. + * + * Audit finding (ROUND16 §F2, the route-level half of the class ROUND15 §F1 + * fixed inside ResourceList): each route paged its rows in via + * `raw = [...raw, ...page.items]` and rebuilt a brand-new Map — hashing every + * accumulated id — on EVERY page. O(N) per page ⇒ Σ O(N²/page) across a drain, + * plus a fresh Map instance each page. The fix holds one persistent map and + * sets only the fresh page's entries (mirrors the shipped `favoriteIds` + * SvelteSet, ROUND14 §F2). + * + * Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI): + * 1. Equivalence — at EVERY page, the incrementally-primed map is deep-equal + * to a full `new Map(cumulative.map(entry))` rebuild, including skipped + * entries (drives → null) and the reset path. + * 2. Perf — `entry` work collapses from Σ O(N²/page) to O(N) across the drain + * (deterministic call count) and wall drops ≥3x. + */ + +interface Ctx { + date: string | null; + ownerId: string | null; +} +interface Raw { + resource: { id: string; updated_by: string | null }; + resource_type: 'file' | 'folder' | 'drive'; + accessed_at: string; +} + +const pad = (i: number) => i.toString().padStart(6, '0'); + +/** Item `i`; every 10th is a `drive` (skipped by the shared-with-me-style entry). */ +function raw(i: number): Raw { + return { + resource: { id: `res-${pad(i)}`, updated_by: `user-${i % 8}` }, + resource_type: i % 10 === 0 ? 'drive' : i % 3 === 0 ? 'folder' : 'file', + accessed_at: `2026-07-${pad((i % 27) + 1).slice(-2)}` + }; +} + +/** Maps a raw item to its `[id, ctx]`, skipping drives (returns null) — counts calls. */ +function makeEntry(counter?: { n: number }): (it: Raw) => readonly [string, Ctx] | null { + return (it) => { + if (counter) counter.n++; + if (it.resource_type === 'drive') return null; + return [it.resource.id, { date: it.accessed_at, ownerId: it.resource.updated_by }]; + }; +} + +/** Verbatim BEFORE: the old derive — a fresh Map hashing the whole cumulative list. */ +function rebuild( + cumulative: Raw[], + entry: (it: Raw) => readonly [string, Ctx] | null +): Map { + const m = new Map(); + for (const it of cumulative) { + const e = entry(it); + if (e !== null) m.set(e[0], e[1]); + } + return m; +} + +const PAGE = 50; +const PAGES = 50; // 2 500-item drain + +describe('incremental contextMap (benchmark gate)', () => { + it('stays deep-equal to the full rebuild at every page (incl. skipped drives)', () => { + const all = Array.from({ length: PAGE * PAGES }, (_, i) => raw(i)); + const entry = makeEntry(); + const map = new Map(); + for (let p = 1; p <= PAGES; p++) { + const page = all.slice((p - 1) * PAGE, p * PAGE); + primeContextPage(map, p === 1, page, entry); + const reference = rebuild(all.slice(0, p * PAGE), entry); + expect(new Map(map), `page ${p}`).toEqual(reference); + } + }); + + it('clears on reset and re-primes to the reset page only', () => { + const all = Array.from({ length: 200 }, (_, i) => raw(i)); + const entry = makeEntry(); + const map = new Map(); + primeContextPage(map, true, all.slice(0, 100), entry); + primeContextPage(map, false, all.slice(100, 150), entry); + // Reset with a disjoint page: prior ids must be gone. + const resetPage = all.slice(150, 200); + primeContextPage(map, true, resetPage, entry); + expect(new Map(map)).toEqual(rebuild(resetPage, entry)); + }); + + it('collapses entry work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => { + const N = PAGE * PAGES; + const all = Array.from({ length: N }, (_, i) => raw(i)); + + // Deterministic call-count gate (the hard rollback gate): incremental + // computes each item's entry exactly once; the rebuild is quadratic. This + // holds regardless of machine load. + const afterCounter = { n: 0 }; + const afterEntry = makeEntry(afterCounter); + const countMap = new Map(); + for (let p = 1; p <= PAGES; p++) { + primeContextPage(countMap, p === 1, all.slice((p - 1) * PAGE, p * PAGE), afterEntry); + } + const beforeCounter = { n: 0 }; + const beforeEntry = makeEntry(beforeCounter); + for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), beforeEntry); + expect(afterCounter.n).toBe(N); + expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2); + expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5); + + // Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise + // under a saturated test runner (mirrors round14 §F1's `Math.min` pattern). + const entry = makeEntry(); + const runAfter = () => { + const m = new Map(); + const t = performance.now(); + for (let p = 1; p <= PAGES; p++) { + primeContextPage(m, p === 1, all.slice((p - 1) * PAGE, p * PAGE), entry); + } + return performance.now() - t; + }; + const runBefore = () => { + const t = performance.now(); + for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), entry); + return performance.now() - t; + }; + const afterMs = Math.min(runAfter(), runAfter(), runAfter()); + const beforeMs = Math.min(runBefore(), runBefore(), runBefore()); + + console.info( + `contextMap ${PAGES}×${PAGE}: before ${beforeCounter.n} entry calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)` + ); + + expect(afterMs).toBeLessThan(beforeMs / 3); + }); +}); diff --git a/frontend/src/lib/utils/listContext.ts b/frontend/src/lib/utils/listContext.ts new file mode 100644 index 00000000..2da7744d --- /dev/null +++ b/frontend/src/lib/utils/listContext.ts @@ -0,0 +1,40 @@ +/** + * Incremental maintenance of a grouped-listing route's per-item `contextMap` + * (the `id → ItemContext` envelope `ResourceList` reads via `ctxOf`). + * + * The trash / recent / favorites / shared-with-me routes page their rows in via + * infinite scroll (`raw = [...raw, ...page.items]`) and each derived its + * contextMap as `new Map(raw.map((it) => [id, ctx]))` — rebuilding a brand-new + * Map, hashing every accumulated id, on EVERY page. O(N) per page ⇒ O(N²) + * across a drain, and a fresh Map instance each page invalidated every reader + * (ROUND15 landed the `sections` half of this class inside `ResourceList` but + * left the route-level projection that feeds it untouched). + * + * {@link primeContextPage} mirrors the shipped `favoriteIds` fix (ROUND14 §F2, + * `SvelteSet` primed per page): the route holds ONE persistent reactive map + * (`SvelteMap`) for the component's lifetime and, in `load()`, clears it on a + * reset and sets only the freshly-fetched page's entries — O(page) per page, + * O(N) across the drain, one stable instance. The map only ever needs to be a + * superset of the currently-displayed ids: rows removed by a delete are no + * longer rendered, so their now-stale entries are never read (identical + * reasoning to `favoriteIds`). Every id entering `raw` comes through a + * `load()` page, so the map always covers what is on screen. + * + * The param is typed `Map` (not `SvelteMap`) so the benchmark can drive the + * exact same update logic against a plain Map, decoupled from Svelte + * reactivity — the same way `round14.bench.test.ts` benches the `favoriteIds` + * set. Callers pass their `SvelteMap` at runtime. + */ +export function primeContextPage( + map: Map, + reset: boolean, + page: Iterable, + /** Map one fetched item to its `[id, ctx]` entry, or `null` to skip it (e.g. drives). */ + entry: (item: Raw) => readonly [string, C] | null +): void { + if (reset) map.clear(); + for (const item of page) { + const e = entry(item); + if (e !== null) map.set(e[0], e[1]); + } +} diff --git a/frontend/src/lib/utils/resourceSections.ts b/frontend/src/lib/utils/resourceSections.ts index b62f8de8..0423a50b 100644 --- a/frontend/src/lib/utils/resourceSections.ts +++ b/frontend/src/lib/utils/resourceSections.ts @@ -27,6 +27,8 @@ * gate asserts the incremental builder stays deep-equal to it at every page. */ +import { isAppendExtension } from './appendExtension'; + /** One swimlane: a bucket key, its (possibly async-resolved) header label, and its rows. */ export interface ResourceSection { key: string; @@ -107,15 +109,6 @@ export class ResourceSectionsBuilder { /** False until a grouped sync has populated the accumulation state. */ #grouped = false; - /** Whether `next` extends `prev` (same prefix objects + strictly longer). */ - #isAppend(prev: T[], next: T[]): boolean { - if (next.length <= prev.length) return false; - // Prefix identity via the boundary object — O(1); the list is only ever - // mutated by appending a page or by replacing it with a filtered copy - // (which preserves element identity). - return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1]; - } - #rebuild(items: T[], grouping: SectionGrouping): void { const bucketOf = grouping.bucketOf!; this.#order = []; @@ -172,7 +165,7 @@ export class ResourceSectionsBuilder { if ( this.#grouped && this.#bucketOf === grouping.bucketOf && - this.#isAppend(this.#items, items) + isAppendExtension(this.#items, items) ) { this.#extend(items, grouping); } else { diff --git a/frontend/src/lib/utils/sharedLanes.bench.test.ts b/frontend/src/lib/utils/sharedLanes.bench.test.ts new file mode 100644 index 00000000..2a836f11 --- /dev/null +++ b/frontend/src/lib/utils/sharedLanes.bench.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import { SharedLanesBuilder, buildLanes, type Lane, type LaneGrouping } from './sharedLanes'; + +/** + * Benchmark gate for the incremental lanes builder (SharedLanesBuilder) that + * replaced the `lanes` `$derived.by` on the "My shares" page + * (shared/+page.svelte). + * + * Audit finding (ROUND15 deferred): the shares page pages its outgoing grants + * in via `raw = [...raw, ...page.items]`, and `lanes` re-bucketed the WHOLE + * accumulated (filtered) list on every page — and on every grant edit — + * allocating a fresh lane object + a fresh `rows` array for every lane each + * time. Σ ≈ O(N²/page) `emit` calls during an infinite-scroll drain. Same + * class as the F1 flagship (ResourceList.sections), but the lanes shape fans + * one item out to many rows across many lanes and caches a header at first + * appearance — see sharedLanes.ts. + * + * Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI): + * 1. Equivalence — at EVERY page of the drain, the incremental output is + * deep-equal to the verbatim full-rebuild reference (buildLanes), for the + * by-files group-by (1 lane per resource, contiguous) AND the by-subject + * group-by (a resource's grants scatter across subject lanes, so a fresh + * page sprays rows into already-emitted lanes — non-contiguous). + * 2. Reference stability — untouched lanes keep their exact `rows` array + * reference across a page append; a grown lane gets a fresh one. + * 3. Fallback — group-by switch, grant edit / deletion and kind-filter toggle + * fall back to a correct full rebuild. + * 4. Perf — `emit` work collapses from Σ O(N²/page) to O(N) across the drain + * (deterministic call count) and wall drops ≥3x. + */ + +interface Grant { + grant_id: string; + subject_type: 'user' | 'group' | 'link'; + subject_id: string; + has_password: boolean; +} +interface Item { + resource: { id: string; name: string }; + grants: Grant[]; +} +type Header = + | { kind: 'resource'; item: Item } + | { kind: 'user'; id: string } + | { kind: 'group'; id: string } + | { kind: 'linkPublic' } + | { kind: 'linkPassword' }; +type Row = { grant: Grant; item: Item }; + +const pad = (i: number) => i.toString().padStart(6, '0'); + +/** + * Item `i` with 3 grants: two user grants whose subject round-robins across a + * small pool (so by-subject buckets repeat across items → non-contiguous), and + * one link grant (public / password alternating). Mirrors the shape the shares + * endpoint returns. + */ +function item(i: number): Item { + return { + resource: { id: `res-${pad(i)}`, name: `file-${pad(i)}` }, + grants: [ + { + grant_id: `g-${pad(i)}-0`, + subject_type: 'user', + subject_id: `user-${i % 8}`, + has_password: false + }, + { + grant_id: `g-${pad(i)}-1`, + subject_type: 'group', + subject_id: `group-${i % 5}`, + has_password: false + }, + { + grant_id: `g-${pad(i)}-2`, + subject_type: 'link', + subject_id: '', + has_password: i % 2 === 0 + } + ] + }; +} + +/** By-files group-by: one lane per resource; the old derive's unconditional `ensure`. */ +function itemsGrouping(counter?: { n: number }): LaneGrouping { + return { + groupKey: 'items', + emit: (it, sink) => { + if (counter) counter.n++; + const key = `resource:${it.resource.id}`; + const header: Header = { kind: 'resource', item: it }; + sink.open(key, header); + for (const grant of it.grants) sink.push(key, header, { grant, item: it }); + } + }; +} + +/** By-subject group-by: a resource's grants scatter across one lane per subject / link kind. */ +function sharedWithGrouping(counter?: { n: number }): LaneGrouping { + return { + groupKey: 'sharedWith', + emit: (it, sink) => { + if (counter) counter.n++; + for (const grant of it.grants) { + let key: string; + let header: Header; + if (grant.subject_type === 'user') { + key = `user:${grant.subject_id}`; + header = { kind: 'user', id: grant.subject_id }; + } else if (grant.subject_type === 'group') { + key = `group:${grant.subject_id}`; + header = { kind: 'group', id: grant.subject_id }; + } else if (grant.has_password) { + key = 'links:password'; + header = { kind: 'linkPassword' }; + } else { + key = 'links:public'; + header = { kind: 'linkPublic' }; + } + sink.push(key, header, { grant, item: it }); + } + } + }; +} + +const PAGE = 50; +const PAGES = 50; // 2 500-item drain + +describe('incremental shared lanes (benchmark gate)', () => { + for (const [name, mk] of [ + ['by-files (contiguous, 1 lane/resource)', itemsGrouping], + ['by-subject (non-contiguous fan-out)', sharedWithGrouping] + ] as const) { + it(`stays deep-equal to the full rebuild at every page — ${name}`, () => { + const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i)); + const builder = new SharedLanesBuilder(); + const g = mk(); + for (let p = 1; p <= PAGES; p++) { + const cumulative = all.slice(0, p * PAGE); + const incremental = builder.sync(cumulative, g); + const reference = buildLanes(cumulative, g); + expect(incremental, `page ${p}`).toEqual(reference); + } + }); + } + + it('keeps untouched lane arrays reference-stable and refreshes grown ones', () => { + // A grouping that yields both stable and grown lanes on append: each item + // contributes to a per-block lane (block = ⌊i/40⌋, so older blocks are + // untouched by a later page) AND a single global lane (grows every page). + const grouping: LaneGrouping = { + groupKey: 'blocks', + emit: (it, sink) => { + const i = Number(it.resource.id.slice(4)); + const blockKey = `block:${Math.floor(i / 40)}`; + sink.push(blockKey, { kind: 'user', id: blockKey }, { grant: it.grants[0], item: it }); + sink.push('all', { kind: 'user', id: 'all' }, { grant: it.grants[1], item: it }); + } + }; + const all = Array.from({ length: 200 }, (_, i) => item(i)); + const builder = new SharedLanesBuilder(); + + const first = builder.sync(all.slice(0, 120), grouping); + const refBefore = new Map(first.map((l) => [l.key, l.rows])); + + const second = builder.sync(all.slice(0, 160), grouping); + const refAfter = new Map(second.map((l) => [l.key, l.rows])); + + // Old blocks (0,1,2 = items 0..119) are untouched → same array reference. + expect(refAfter.get('block:0')).toBe(refBefore.get('block:0')); + expect(refAfter.get('block:2')).toBe(refBefore.get('block:2')); + // The global lane grew → a fresh reference (a keyed {#each} re-renders it). + expect(refAfter.get('all')).not.toBe(refBefore.get('all')); + // And a brand-new block appeared for items 120..159. + expect(refBefore.has('block:3')).toBe(false); + expect(refAfter.has('block:3')).toBe(true); + }); + + it('falls back to a correct full rebuild on group-by switch, edit and filter toggle', () => { + const all = Array.from({ length: 300 }, (_, i) => item(i)); + const builder = new SharedLanesBuilder(); + const byItems = itemsGrouping(); + const bySubject = sharedWithGrouping(); + + // Drain a few pages by-files, then switch to by-subject (groupKey change → rebuild). + builder.sync(all.slice(0, 150), byItems); + builder.sync(all.slice(0, 300), byItems); + expect(builder.sync(all.slice(0, 300), bySubject)).toEqual( + buildLanes(all.slice(0, 300), bySubject) + ); + + // Grant edit under the SAME group-by: an item's grants change but the item + // list length is unchanged → not a strict append → rebuild. Mutate a copy. + const edited = all + .slice(0, 300) + .map((it, i) => (i === 10 ? { ...it, grants: it.grants.slice(0, 1) } : it)); + expect(builder.sync(edited, bySubject)).toEqual(buildLanes(edited, bySubject)); + + // Deletion (list shrinks) → rebuild. + const shrunk = edited.filter((_, i) => i % 9 !== 0); + expect(builder.sync(shrunk, bySubject)).toEqual(buildLanes(shrunk, bySubject)); + + // Kind-filter toggle: the filtered list becomes a different (reordered) + // subset → boundary mismatch → rebuild, still equal to a full pass. + const filtered = all.slice(0, 300).filter((_, i) => i % 3 === 0); + expect(builder.sync(filtered, byItems)).toEqual(buildLanes(filtered, byItems)); + }); + + it('collapses emit work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => { + const N = PAGE * PAGES; + const all = Array.from({ length: N }, (_, i) => item(i)); + + // Deterministic call-count gate (the hard rollback gate): the incremental + // builder emits each item exactly once across the drain; the full rebuild + // is quadratic (Σ_{p=1..P} p·PAGE). This is noise-free — it holds regardless + // of machine load. + const afterCounter = { n: 0 }; + const gAfterCount = sharedWithGrouping(afterCounter); + const countBuilder = new SharedLanesBuilder(); + for (let p = 1; p <= PAGES; p++) countBuilder.sync(all.slice(0, p * PAGE), gAfterCount); + const beforeCounter = { n: 0 }; + const gBeforeCount = sharedWithGrouping(beforeCounter); + for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), gBeforeCount); + expect(afterCounter.n).toBe(N); + expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2); + expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5); + + // Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise + // under a saturated test runner (mirrors round14 §F1's `Math.min` pattern); + // the tiny incremental arm is otherwise vulnerable to a single GC pause. + // The O(N²)→O(N) collapse leaves ample headroom over the 3x floor. + const runAfter = () => { + const b = new SharedLanesBuilder(); + const g = sharedWithGrouping(); + const t = performance.now(); + for (let p = 1; p <= PAGES; p++) b.sync(all.slice(0, p * PAGE), g); + return performance.now() - t; + }; + const runBefore = () => { + const g = sharedWithGrouping(); + const t = performance.now(); + for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), g); + return performance.now() - t; + }; + const afterMs = Math.min(runAfter(), runAfter(), runAfter()); + const beforeMs = Math.min(runBefore(), runBefore(), runBefore()); + + console.info( + `shared lanes ${PAGES}×${PAGE}: before ${beforeCounter.n} emit calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)` + ); + + expect(afterMs).toBeLessThan(beforeMs / 3); + }); +}); + +// Keep the exported types referenced so a stray unused-import lint can't creep in. +export type { Lane }; diff --git a/frontend/src/lib/utils/sharedLanes.ts b/frontend/src/lib/utils/sharedLanes.ts new file mode 100644 index 00000000..8ef5c25f --- /dev/null +++ b/frontend/src/lib/utils/sharedLanes.ts @@ -0,0 +1,188 @@ +/** + * Incremental swimlane builder for the "My shares" page (`shared/+page.svelte`). + * + * The ROUND15-deferred follow-up to the F1 flagship (`resourceSections.ts`): + * that fix replaced `ResourceList`'s `sections` derive; this one replaces the + * `lanes` `$derived.by` on the shares page, which had the same O(N²/page) + * shape. The page pages its outgoing grants in via infinite scroll + * (`raw = [...raw, ...page.items]`), and `lanes` re-bucketed the WHOLE + * accumulated (filtered) list on every page — and on every grant edit — + * allocating a brand-new lane object and a brand-new `rows` array for every + * lane each time. Σ ≈ O(N²/page) `emit` calls across an infinite-scroll drain. + * + * The lanes shape differs from `resourceSections` in two ways, so it gets its + * own builder rather than reusing `ResourceSectionsBuilder` (only the O(1) + * append test is genuinely shared — see {@link isAppendExtension}): + * + * - **Fan-out.** One input item contributes 0..N rows across 0..M lanes (in + * the "shared with" group-by a resource's grants scatter across one lane per + * distinct subject), whereas a resource section maps one item to exactly one + * bucket with the item itself as the row. + * - **Header captured at first appearance.** A lane's header (a tagged union + * identifying the resource / subject / link kind) is fixed by the lane's + * first-seen member and never recomputed — unlike a section's `label`, which + * is recomputed every sync because it resolves async. (The shares page mirrors + * that: it renders the header's *label* live via `resolveLabel(...)` at render + * time from the stable header, so only the header identity is cached here.) + * + * Correctness does not depend on lane contiguity in server order. The + * "shared with" group-by is non-monotonic — a fresh page sprays rows across + * already-emitted subject lanes — exactly like F1's "trash by drive" case, and + * stays byte-for-byte equal to a full rebuild (it just refreshes more lanes per + * page). The pure {@link buildLanes} is the verbatim reference (what the old + * `lanes` derive produced); the benchmark gate holds the incremental builder + * deep-equal to it at every page. + */ + +import { isAppendExtension } from './appendExtension'; + +/** One swimlane: a stable key, its first-appearance header, and its rows. */ +export interface Lane { + key: string; + header: H; + rows: R[]; +} + +/** + * Sink an item's {@link LaneGrouping.emit} writes its contributions to. + * `open` ensures a lane exists (0 rows is valid — mirrors the old derive's + * unconditional `ensure(...)` in the by-files group-by); `push` ensures the + * lane and appends a row. The `header` is consulted only when the key is first + * seen. + */ +export interface LaneSink { + open(key: string, header: H): void; + push(key: string, header: H, row: R): void; +} + +/** + * The grouping the builder needs: a `groupKey` identity (a change forces a full + * rebuild) and an `emit` that maps one item to its lane contributions via the + * {@link LaneSink}. Generic over item `T`, header `H` and row `R` so the module + * stays independent of the shares page's concrete types. + */ +export interface LaneGrouping { + /** Identity of the active grouping; a change between syncs forces a rebuild. */ + groupKey: string; + /** Emit an item's lane contributions, in order, into `sink`. */ + emit: (item: T, sink: LaneSink) => void; +} + +/** + * Verbatim reference: the `Lane[]` the old `lanes` `$derived.by` produced for + * `items` under `grouping`. Lane order is first-appearance; within a lane, row + * order is (item, then emit) order. The benchmark gate holds the incremental + * builder equal to this at every page. + */ +export function buildLanes(items: T[], grouping: LaneGrouping): Lane[] { + const out: Lane[] = []; + const byKey = new Map>(); + const ensure = (key: string, header: H): Lane => { + let lane = byKey.get(key); + if (lane === undefined) { + lane = { key, header, rows: [] }; + byKey.set(key, lane); + out.push(lane); + } + return lane; + }; + const sink: LaneSink = { + open: (key, header) => void ensure(key, header), + push: (key, header, row) => ensure(key, header).rows.push(row) + }; + for (const item of items) grouping.emit(item, sink); + return out; +} + +/** + * Incremental lanes builder. Call {@link sync} with the current (already + * kind-filtered) item list and grouping on every change; it detects the common + * case — the list grew by appending a page under an unchanged group-by — and + * re-emits only the fresh items, appending to the touched lanes (each of which + * gets a fresh `rows` array so a keyed `{#each}` re-renders it) while every + * untouched lane keeps its exact array reference. Any other change (group-by + * switch, grant edit / deletion, kind-filter toggle, non-append) falls back to + * a full rebuild, so the result is always deep-equal to {@link buildLanes}. + */ +export class SharedLanesBuilder { + /** Last synced list — the append cursor and the append-detection baseline. */ + #items: T[] = []; + /** Lane keys in first-appearance order. */ + #order: string[] = []; + /** key → the lane's first-appearance header. */ + #headers = new Map(); + /** key → the lane's rows array (a fresh reference whenever it grows). */ + #rows = new Map(); + /** The `groupKey` of the last sync; a change forces a rebuild. */ + #groupKey: string | null = null; + + #rebuild(items: T[], grouping: LaneGrouping): void { + this.#order = []; + this.#headers = new Map(); + this.#rows = new Map(); + const ensure = (key: string, header: H): R[] => { + let arr = this.#rows.get(key); + if (arr === undefined) { + arr = []; + this.#rows.set(key, arr); + this.#headers.set(key, header); + this.#order.push(key); + } + return arr; + }; + const sink: LaneSink = { + open: (key, header) => void ensure(key, header), + push: (key, header, row) => ensure(key, header).push(row) + }; + for (const item of items) grouping.emit(item, sink); + this.#items = items; + } + + #extend(items: T[], grouping: LaneGrouping): void { + const fresh = items.slice(this.#items.length); + // Collect the fresh page's rows per touched lane, plus the keys the page + // newly introduces (in first-appearance order). Untouched lanes are never + // entered here, so they keep their exact existing `rows` reference. + const freshByKey = new Map(); + const newKeys: string[] = []; + const touch = (key: string, header: H): R[] => { + let add = freshByKey.get(key); + if (add === undefined) { + add = []; + freshByKey.set(key, add); + if (!this.#rows.has(key)) { + newKeys.push(key); + this.#headers.set(key, header); + } + } + return add; + }; + const sink: LaneSink = { + open: (key, header) => void touch(key, header), + push: (key, header, row) => touch(key, header).push(row) + }; + for (const item of fresh) grouping.emit(item, sink); + for (const [k, add] of freshByKey) { + const existing = this.#rows.get(k); + // New lane → adopt the fresh array; grown lane → fresh concat (new + // reference, so a keyed `{#each}` refreshes exactly the grown lanes). + this.#rows.set(k, existing === undefined ? add : existing.concat(add)); + } + for (const k of newKeys) this.#order.push(k); + this.#items = items; + } + + sync(items: T[], grouping: LaneGrouping): Lane[] { + if (this.#groupKey === grouping.groupKey && isAppendExtension(this.#items, items)) { + this.#extend(items, grouping); + } else { + this.#rebuild(items, grouping); + } + this.#groupKey = grouping.groupKey; + return this.#order.map((k) => ({ + key: k, + header: this.#headers.get(k)!, + rows: this.#rows.get(k)! + })); + } +} diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index a52aa3f1..d19afdac 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -1,5 +1,6 @@