Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).
Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.
Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
on reset) vs a brand-new set over the whole accumulated list each page:
22.3x over a 40-page drain (O(N^2)→O(N)).
Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN
16 KiB
Round 14 — narrow projections, per-request auth allocations, CalDAV read-emitter buffers, frontend set churn
Benchmark-gated, same rule as ROUND2–13: 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 is a broad micro-sweep: one over-fetch on the People lightbox path,
five per-request allocations on the authenticated /api + DAV hot path, the
CalDAV read-emitters (which never got the allocation treatment their CardDAV
twin already ships), and two frontend per-page set-churn fixes.
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (release profile for the Rust examples; Node 22 / vitest 4 for the frontend). Reproduce any row with the command in its section.
Summary
| # | change | key metric | before → after |
|---|---|---|---|
| Q1 | Lightbox face boxes — narrow SELECT id, person_id, bbox with the caller filter in SQL, vs hydrating the full 10-column row (incl. the 2 KiB embedding BYTEA, decoded per face) and filtering in Rust |
15-face group photo | 0.312 → 0.219 ms (1.43×) · 32 040 → 840 B/req (38× less wire, scales with face count) |
| A1 | Cookie auth reads the access token with the borrow-only extract_cookie_str (already backs CSRF) instead of extract_cookie_value's owned String |
per cookie-authed /api req |
157.7 → 146.5 ns · 1 → 0 allocs |
| A2 | compute_relevance ASCII case-fold fast path vs name.to_lowercase() per result row (Unicode fallback preserved) |
12-row result page | 661.9 → 473.6 ns (1.40×) · 12 → 3 allocs |
| A3 | sub pre-parsed to Uuid at decode time vs re-parsing the 36-char claim on every request (even cache hits) |
per authed req | 22.7 → 0.7 ns (32.9×) CPU |
| A4 | Auth middleware borrows request.headers() instead of taking axum's HeaderMap extractor (a full map clone) — JWT and NextCloud paths |
per authed /api+DAV+NC req |
239.1 → 7.6 ns (31.5×) · 2 → 0 allocs |
| A5 | CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to chrono) vs updated_at.to_rfc2822() heap String per event |
5 events | 178.2 → 148.7 ns · 5 → 0 allocs |
| A6 | CalDAV per-event href + quoted etag written into reused page buffers vs a fresh format! String pair per event |
40-event page | 8 399 → 2 417 ns (3.48×) · 240 → 6 allocs |
| F1 | t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs a throwaway {} per call |
4M no-param calls | 34.1 → 28.8 ms (1.18×) · −1 alloc/call |
| F2 | Favorites favoriteIds is a persistent set with per-page add vs a brand-new SvelteSet over the whole accumulated list each infinite-scroll page |
40-page drain | 35.5 → 1.6 ms (22.3×) · O(N²) → O(N) |
[Q1] Lightbox face boxes — narrow projection + SQL-side caller filter
cargo run --release --features bench --example bench_round14_queries # §Q1
GET /api/people/faces/{file_id} fires on every lightbox open of a
face-tagged photo. people_service::faces_for_file (the sole caller of the
repo method) builds FaceBoxDto { id, person_id, x,y,w,h } — it reads only
id, person_id, bbox. But the repo's faces_for_file selected all ten
columns, dragging the 2 048-byte embedding BYTEA (512 × f32) across the
wire and decoding it into a Vec<f32> per face (row_to_face), plus five
more unused columns, then filtered user_id == caller in Rust. For a group
photo that is ~2.1 KB/face fetched where ~40 B is needed.
The fix mirrors the already-accepted person_face_stats narrowing (the port
doc there already cites "the 2 KiB embedding BYTEA per row"): a new
face_boxes_for_file(file_id, user_id) port method selects only
id, person_id, bbox and pushes the caller scope into WHERE user_id = $2
(driven by idx_faces_file), returning a lightweight FaceBox. 15-face group
photo: 0.312 → 0.219 ms, 32 040 → 840 B/req; the margin widens with face count
and is larger over a networked PG. Gate: the {(id, person_id, bbox)} set is
byte-identical before/after (all 15 faces), and the caller scope is preserved
(now enforced in SQL rather than a Rust .filter).
[A1]–[A6] Auth + CalDAV micro-pack
cargo run --release --features bench --example bench_round14_micro # §A1–§A6
Counting-allocator micro-bench; each section is BEFORE (the shipped shape, or the shipped function itself) vs AFTER, with a byte-identity/equivalence gate.
- [A1] Cookie token extract.
auth_middleware's cookie arm calledextract_cookie_value→ an ownedStringwhose only use is to be reborrowed as&strintovalidate_token. The borrow-only twinextract_cookie_stralready exists (it backs the CSRF middleware, ROUND11 §6). Swapped: −1 alloc on every SPA/browser/apirequest. Gate: byte-identical value. - [A2]
compute_relevanceASCII fast path. The query side was already hoisted, but the name side still didname.to_lowercase()(full Unicode) per result row — and per keystroke on the suggest path. For the overwhelmingly common all-ASCII filename that is pure waste. New path:eq_ignore_ascii_case+ an allocation-free ASCII case-insensitivestarts_with/contains; non-ASCII names fall back to the exact Unicode-lowercase comparison. 12-row page: 1.40×, 12 → 3 allocs. Gate: the ASCII path equals the Unicode path across a mixed ASCII/é/ß/ïcorpus (exact/prefix/substring/miss). - [A3]
sub→Uuidpre-parse.TokenClaims.subis aString; the middleware re-ranUuid::parse_stron the 36-char subject on every request, downstream of the validation cache (which returns the sameArc<TokenClaims>), so the parse repeated on ~all-hit steady state. A newsub_id: Uuidis parsed once inFrom<JwtClaims>(amortized over the cache TTL); the middleware reads aCopy. 22.7 → 0.7 ns. A verified token we signed always carries a UUID sub; the nil sentinel is rejected defensively, exactly like the old parse-error branch. Gate: pre-parsedsub_idequals a fresh parse. - [A4] Drop the
HeaderMapclone. Bothauth_middleware(JWT/Basic/cookie — all/api, WebDAV, CalDAV, CardDAV) and the NextCloudbasic_auth_middlewaretook axum'sHeaderMapextractor, i.e. a fullparts.headers.clone()(~2 allocs) per request, purely to read the Authorization/Cookie headers. Removed; the middleware borrowsrequest.headers()directly. This is a borrow restructuring, not a logic change: the header borrow is dead (NLL) by the time each arm reachesrequest.extensions_mut()/next.run(request), so no owned copy is needed and the auth decisions are byte-identical. 239.1 → 7.6 ns, 2 → 0 allocs — the single highest-reach allocation removed this round. Gate: the token extracted from a cloned map equals the token from the borrowed map. - [A5] CalDAV
getlastmodifiedstack render. The CalDAV read-emitters (write_report_page→ event props,write_collection_event_page, and the two per-calendar prop writers) formattedupdated_at.to_rfc2822()into a fresh heapStringper event — up toCALDAV_STREAM_PAGE_EVENTS = 500per page, on the REPORT (calendar-query/multiget/sync-collection) and collection-PROPFIND paths every client polls constantly. The CardDAV twin already replaced exactly this with the[u8; 31]stack renderercommon::fmt::rfc2822_utc(ROUND10 §13), parity-tested byte-for-byte against chrono across 60 years, with the chrono fallback for out-of-4-digit-year timestamps. Ported via a sharedwrite_lastmodified_texthelper at all five sites: 5 → 0 allocs. Gate: stack render byte-identical toto_rfc2822. - [A6] CalDAV per-event
href+etagreused buffers. Same emitters allocated a freshformat!("{}{}.ics", …)href and aformat!("\"{}\"", id)quoted etagStringper event. The CardDAV emitter already reuses a single page buffer (clear()+write!). Ported:write_report_page/write_collection_event_pagehold reusablehref+etagbuffers threaded throughwrite_event_responseinto the prop writers (its only caller), so a 40-event page allocates that storage once, not 80 times. 3.48×, 240 → 6 allocs/page. Gate: reused-buffer bytes identical to the per-eventformat!.
[F1][F2] Frontend set/alloc micro-pack
cd frontend && npx vitest run src/lib/components/round14.bench.test.ts
- [F1]
t()shared empty params. The ubiquitoust('k', 'Fallback')and baret('k')(default= {}) allocated a throwaway params object on every call, though for a cache-hit string with no{{…}}interpolatereturns before reading params.t()runs ~10×/row. Hoisted one frozenEMPTY_PARAMS; 4M no-param calls 34.1 → 28.8 ms (the alloc reduction shows as ~1.18× even on V8's cheap young-gen{}). Gate: identical output for the bare / string-fallback / params forms; perf gate requires the shared arm be no slower. - [F2] Favorites
favoriteIdsincremental set. The favorites route derivedfavoriteIds = new SvelteSet(items.map(i => i.id)). Every infinite-scroll page (raw = [...raw, ...page]) rebuilt a brand-new set over the whole accumulated list — O(N) per page, O(N²) across a drain — and, being a new instance each page, invalidated every mounted star reader. Since every item on this page is a favorite and removed items aren't rendered, the set only has to be a superset of the displayed ids, so the fix keeps one persistentSvelteSetandadds only the fresh page's ids (clearon reset,deleteon unfavorite) — the shaperecentalready ships (replaceSet, ROUND6). A 40-page × 50 drain: 35.5 → 1.6 ms (22.3×). Gate: final membership identical to the rebuild-per-page model.
Not shipped — investigated, deferred, or flagged
Every item below was surfaced and verified this round but deliberately left out of the benchmark-gated set — either it needs a decision the perf pass can't make, or it isn't cleanly wall-benchable, or it's a correctness bug that must not ride a perf banner.
Query-shape (needs Postgres; verified, deferred)
music_storage_adapter::list_public_playlists1 + NCOUNT(*)— oneSELECT COUNT(*) FROM audio.playlist_itemsper playlist (up to 101 round-trips atlimit=100). Foldable into oneLEFT JOIN … GROUP BY. It's the public-gallery path (include_publicdefaults false), so opt-in; queued with its bench. Its two dead siblingslist_playlists_by_owner/list_shared_with_usercarry the same N+1 with no live caller (replaced byget_playlists_by_idspost-ROUND3) — flag for deletion, not optimization.- Contact REST listings over-fetch the
vcardTEXT —search_contacts,get_contacts_by_address_book_paginated, andget_contacts_in_groupselect the full serialized card (the largest column; multi-KB with an embeddedPHOTO;ENCODING=b), but every caller maps toContactDto, which has novcardfield. Wants a lite row mapper (the non-paginated sibling is shared with the CardDAV stream, which genuinely needsvcard), so it's a contained refactor rather than a blanket SELECT change.
CPU/alloc (verified, deferred or below the noise floor)
content_index_worker: (a) clones the full extracted text into the per-batchtext_by_hashmap even for unique blobs (dead clone in the common one-file-per-blob case; holdArc<str>or gate on multiplicity); (b) callstext_extractor::supports()(which lowercases MIME + extension, 1–2 allocs) twice per file per drain batch. Both are reseed-throughput, not request-latency — worth one worker micro-bench of their own.tantivy_content_index::search_blockingbuilds aSnippetGeneratoreven when there are zero hits — trivialif top_docs.is_empty() { return … }.exif_servicedouble-allocates on Make/Model/GPS-ref (display_value().to_string().trim_matches('"').trim().to_string()— the intermediateto_stringis thrown away). Per-image, background.- REST calendar-event edit re-
format!s the wholeical_databody once per changed property (update_ical_property/remove_ical_property), so a 6-field PATCH reallocates the body ~7×. Per-edit (rare vs CalDAV reads); wants one working buffer.
Storage I/O (cached-remote deployment class; verified, deferred)
CachedBlobBackendre-runsfs::create_dir_all(prefix)per cache write — unlikeLocalBlobBackend::initialize, which pre-creates all 256 prefix dirs; a cached-remote upload pays a redundantmkdir(EEXIST)+stat+ blocking dispatch per chunk. Pre-create at init and drop the hot-path call.CachedBlobBackendeviction listenerstd::fs::remove_fileon the reactor thread — moka delivers the listener on the calling (tokio worker) thread, so at steady state each write-through insert unlinks a victim inline (p99 stall). Hand the unlink tospawn_blocking/ a drain task.dedup_servicehash-Stringre-allocations —distinct_hashesrebuilds a set the streaming loop already had (session_seen);settle_batchclones every batch hash to bind the pin query. Alloc-count only (within noise on a throughput bench); report as such.encrypted_blob_backendemits 64 KiB plaintext frames where every other backend streams 256 KiB (the comment claiming parity is wrong) → 4× frames on decrypted reads; AES dominates, so likely within noise — verify before shipping.
Frontend (bigger refactors — their own pass)
ResourceList.sectionsre-buckets the whole accumulated list per page (O(N²) across a grouped-view drain; trash is grouped-by-default and itsbucketOfdoesDatemath per item). The fix is the provenPhotoTimelineincremental-builder pattern (persistentMap+ append-detection); it's the flagship follow-up, same class as the ROUND13-deferred "unify all four listing arms onto oneVirtualRows".shared/+page.svelterebuilds the fulllanestree per page and on every single grant edit (raw = [...raw]to force reactivity); and the favorites/recent/trash routes re-projectitems/contextMapper page. Co-solved by the same incremental builder.
Already done / correctness (not perf)
- JWT-claims
Arc<str>— the ROUND6/ROUND9-deferred "cheapest known win on the /api path" was already shipped in ROUND10 (TokenClaims.username/email: Arc<str>,CurrentUserbuild = 1 alloc). The residualArc::newis structurally required (shared withNcSession). Do not re-open. - Media hooks' raw blob reads are broken, not merely duplicated (ROUND13
finding stands):
MediaMetadataService/FaceIndexingServiceread.blobs/{hash}.blobdirectly, which only exists for local + unencrypted + single-chunk blobs — silently no capture-date/GPS/faces for the common case. Correctness fix (route throughread_blob_bytes), perf-neutral-to-negative; a shared-Bytesprovider is the perf follow-up once it lands. calendar_event_pg_repository::list_events_by_calendar_paginatedselects the stale 13-column shape (omitsrecurrence_id), flattening exception overrides into masters on paginated listings — a latent correctness bug, not a perf win.
Environment / methodology
cargo run --release --features bench --example bench_round14_queries— needs Postgres; seeds + cleans its own fixtures (BENCH_PASSES,BENCH_FACES_PER_FILE).cargo run --release --features bench --example bench_round14_micro— counting allocator, no Postgres (BENCH_ITERS).cd frontend && npx vitest run src/lib/components/round14.bench.test.ts.- Cross-round guards unchanged (
bench_round10_micro/_queriesupdated for theTokenClaims.sub_idfield and the narrowed faces read-back).