Benchmark-gated (equivalence + BEFORE/AFTER; results + reproduce commands in
benches/ROUND7.md):
- Photos timeline re-grouped + re-laid-out the whole accumulated library on
every 60-item page (both `groups` and `photoRows` were $derived over the
full list), Σ ≈ O(N²/60) main-thread work during a scroll. Pages arrive
newest-first so grouping is append-only: the new PhotoTimeline
(lib/utils/photoTimeline.ts) re-buckets only the fresh page and re-lays-out
only changed groups, reusing untouched groups' cached rows, falling back to
a full rebuild on any config/deletion/non-append change. The pure
buildPhotoRows is the verbatim reference the gate holds it equal to at every
page. 50×60 drain: 76 500 → 3 000 grouping ops (25.5x), 23.0 → 2.2 ms
(10.6x).
- Range downloads paid authz + access-notify twice: download_file_impl
resolves the file via get_file_with_perms, then the Range branch re-ran
require_file + notify_file_accessed per request. Media/PDF viewers fetch
exclusively via Range (one request per seek), so every seek in a scrub
re-authorized an already-cleared file. Now routed through the non-perms
get_file_range_preloaded (matching the share-landing + WebDAV range paths);
the unused _with_perms range method is removed. The request-level gate still
denies before the branch runs (bench asserts member granted, outsider
denied). Per seek removed: WARM 0.67 µs, COLD 1362.66 µs — a grant-cascade
drive-resolve query per seek for a shared-drive recipient on a cold cache.
- /api/folders/{id}/resources row→DTO mapping cloned row.name into the DTO
though the row is owned; folders move it (fixed icons), files compute the
name-derived icon/category classes first then move it. 500-row page:
10.004 → 9.004 allocs/row (500 clones removed), output identical.
Deferred with rationale in ROUND7.md: thumbnail ACL-before-304 (security
posture — needs a security review, not a perf tweak), batch_operations
Arc<str>→String widening, list-view O(N²) on smaller lists, and the serial→
join! pairs (decide-by-bench with injected latency, per the round-6 rejection).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
8.3 KiB
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_implrunsrequire_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 existingdrive_role_cache, or aBorrow<str>cache key that removes the per-requestto_string) is queued for round 8 with an alloc/query bench. batch_operationsArc<str>→Stringper item.copy_file_with_perms/move_file_with_permstakeOption<String>, so the batch path'starget_folder: Arc<str>is re-to_string()-ed per item, defeating the Arc. Widening those_with_permssignatures toOption<&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/lanesover 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::textA/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)
fetchFolderListingreturns emptyfavoriteIds/sharedIds(frontend/src/lib/api/endpoints/folders.ts) since the combined/listingroute 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-memoryfolderCache). Functional regression, not perf.- Search page lacks a stale-response guard
(
frontend/src/routes/search/+page.svelte): the query$effectawaitssearchFileswith noseq/AbortController, so a slow stale query can resolve after and clobber a newer one. The files view'sloadSeqis the pattern to mirror.