# Round 7 — photo timeline O(N²) → incremental, range-seek authz duplication, row-map clone Benchmark-gated changes, same rule as ROUND2-6: every change ships with a BEFORE/AFTER benchmark; an AFTER that doesn't beat its BEFORE gets rolled back. Equivalence gates (identical output / byte-identical responses) guard every behavior-preserving rewrite. Frontend changes carry vitest benchmark gates (verbatim BEFORE replica + equivalence + perf assertion) committed beside the code so CI re-verifies the win on every run. 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 | # | change | key metric | before → after | |--:|---|---|---| | 1 | Photos timeline incremental grouping/layout | 50-page (3k-photo) scroll drain | 76 500 → 3 000 group ops (**25.5x**) / 23.0 → 2.2 ms (**10.6x**) | | 2 | Range-seek per-request authz duplication removed | per-seek authz on a shared-drive scrub | WARM 0.67 → 0 µs/seek; **COLD 1362.66 → 0 µs/seek** (a drive-resolve query per seek) | | 3 | `/resources` row→DTO name clone → move | allocs/row (500-row page) | 10.004 → 9.004 (**500 allocs saved**, 1.00/row) | ## [1] Photos timeline — O(N²) re-group + re-layout per page → incremental builder The photos view appended each 60-item page with `items = [...items, ...page]` and re-derived both `groups` (O(N), a `new Date()` per photo) and `photoRows` (O(N) row layout) over the whole accumulated list on every page — so paging to photo N re-grouped + re-laid-out everything loaded so far, Σ ≈ O(N²/60) of main-thread work during the scroll (the exact class ROUND6 fixed for the files listing). The DOM was already windowed (`VirtualRows`); this was the derivation feeding it. Because photos arrive newest-first (`media_sort_date DESC`), grouping is append-only: a page only ever extends the last date bucket or adds buckets after it, never mutates an earlier group. The new `PhotoTimeline` (`lib/utils/photoTimeline.ts`) exploits that — an append re-buckets only the fresh page and re-lays-out only the groups that changed, reusing every untouched group's cached rows; any other change (config, deletion, filter toggle, non-append) falls back to a full rebuild. The pure `buildPhotoRows` is the verbatim reference the gate holds it equal to. Gates: the incremental output is deep-equal to `buildPhotoRows` at EVERY page of the drain (both square + justified layouts); config-change / deletion / width=0 fall back to a correct full rebuild; grouping work collapses ≥5x and wall ≥3x. ``` cd frontend && npx vitest run src/lib/utils/photoTimeline.bench.test.ts --disable-console-intercept # photo timeline 50×60: before 76500 timestamp reads / 23.0 ms # after 3000 timestamp reads / 2.2 ms # (25.5x fewer grouping ops, 10.6x wall) ``` ## [2] Range downloads — duplicate per-seek authz + access-notify removed `download_file_impl` resolves the file once via `get_file_with_perms` (authz + access-notify + metadata), then the Range branch called `get_file_range_preloaded_with_perms`, which re-ran `require_file` (authz) + `notify_file_accessed` per request. Media players and PDF viewers fetch a file *exclusively* through Range requests — a `bytes=0-` probe then one request per seek — so every seek in a scrub re-authorized a file the request-level gate had already cleared. The share-landing and WebDAV range paths already authorize once then read via the non-perms `get_file_range_preloaded`; the REST handler now does the same (and the now-unused `_with_perms` range method is deleted). Safety: the request-level `get_file_with_perms` still gates every request (denies before the Range branch runs), so the removed per-seek re-check bypasses nothing — the bench asserts the member is granted and a non-member denied. ``` cargo run --release --features bench --example bench_range_seek_authz # seeks/scrub=200 (member of a shared drive, viewer grant) # arm wall ms µs/seek # BEFORE per-seek (WARM) 0.13 0.67 <- moka hit + uuid parse, removed # BEFORE per-seek (COLD) 272.53 1362.66 <- a grant-cascade drive-resolve # QUERY per seek, removed # AFTER per-seek (removed) 0.00 0.00 # A 200-seek scrub of a shared video stops paying ~272 ms of authz queries # when the drive-role cache is cold (cross-drive recipient, or 30 s TTL expiry # mid-scrub). notify_file_accessed (a throttled hook call) is likewise removed # per seek. ``` ## [3] `/api/folders/{id}/resources` row→DTO mapping — clone name → move name The listing maps each owned `FolderResourceRow` into a DTO but cloned `row.name` into it (`name: row.name.clone()`) — one avoidable `String` heap alloc per listed folder/file. The folder branch uses fixed icon classes, so `row.name` is simply moved; the file branch computes its name-derived icon / category classes first (they borrow `&row.name`), then moves `row.name` in. One fewer alloc per row, identical output. ``` cargo run --release --features bench --example bench_resource_row_map # rows=500 # arm allocs wall ms allocs/row # BEFORE (clone) 5002 0.841 10.004 # AFTER (move) 4502 0.810 9.004 # Saved 500 allocs (1.00/row) — the per-row name clone removed; output identical. ``` ## Deferred / flagged (not shipped this round) - **Thumbnail ACL-before-304 (security posture — needs maintainer decision).** `get_thumbnail_impl` runs `require_permission(Read)` before the ETag-304 and moka/disk short-circuits, so a shared-album recipient pays a grant-cascade query per thumbnail revalidation. Moving authz *after* the cache would make thumbnails "authorized at creation time only" — a user whose access was revoked could still fetch cached thumbnails of files they once could see. That is a deliberate security-posture change, not a perf tweak; left for a security review. The safe alternative (back the non-owner authz with the existing `drive_role_cache`, or a `Borrow` cache key that removes the per-request `to_string`) is queued for round 8 with an alloc/query bench. - **`batch_operations` `Arc` → `String` per item.** `copy_file_with_perms` / `move_file_with_perms` take `Option`, so the batch path's `target_folder: Arc` is re-`to_string()`-ed per item, defeating the Arc. Widening those `_with_perms` signatures to `Option<&str>` touches the trait + impl + stub + ~7 call sites — a contained refactor better done deliberately with its own alloc bench; queued for round 8. - **List-view O(N²) re-derive (favorites / recent / trash / shared-with-me / shared swimlanes).** Same class as [1] but on typically-smaller lists; each infinite-scroll page re-derives `entries` / `byId` / `sections` / `lanes` over the full accumulated set. Deferred — the incremental-builder cost isn't yet justified at those sizes; revisit if any surface reaches thousands of rows. - **Serial independent DB pairs → `join!` (token refresh, login, cross-drive move, CardDAV discovery, NC PROPFIND enrichment).** Overlapping independent round-trips saves 1 RTT *under real PG latency*, but the ROUND6 authz-fan-out rejection showed the overhead can wash the win out on local-socket PG. These need a decide-by-bench with an injected-latency arm (like the ROUND6 `::text` A/B) before adoption — queued for round 8, not guessed at here. ## Correctness-adjacent (surfaced by the round-7 hunt — not perf, flagged for follow-up) - **`fetchFolderListing` returns empty `favoriteIds`/`sharedIds`** (`frontend/src/lib/api/endpoints/folders.ts`) since the combined `/listing` route was removed — the files-grid star/shared badges are seeded empty on every navigation. The same removal also dropped the 304 conditional fast-path, so a folder navigation now pages the full body (`cache: no-store`) instead of a bodiless 304 on unchanged folders (mitigated only by the in-memory `folderCache`). Functional regression, not perf. - **Search page lacks a stale-response guard** (`frontend/src/routes/search/+page.svelte`): the query `$effect` awaits `searchFiles` with no `seq`/AbortController, so a slow stale query can resolve after and clobber a newer one. The files view's `loadSeq` is the pattern to mirror.