Files
Oxicloud/benches/ROUND14.md
T
Claude c930f865b0 perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn
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
2026-07-19 10:22:12 +00:00

243 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 called
`extract_cookie_value` → an owned `String` whose only use is to be reborrowed
as `&str` into `validate_token`. The borrow-only twin `extract_cookie_str`
already exists (it backs the CSRF middleware, ROUND11 §6). Swapped: −1 alloc
on every SPA/browser `/api` request. Gate: byte-identical value.
- **[A2] `compute_relevance` ASCII fast path.** The query side was already
hoisted, but the *name* side still did `name.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-insensitive
`starts_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` → `Uuid` pre-parse.** `TokenClaims.sub` is a `String`; the
middleware re-ran `Uuid::parse_str` on the 36-char subject on *every* request,
downstream of the validation cache (which returns the same
`Arc<TokenClaims>`), so the parse repeated on ~all-hit steady state. A new
`sub_id: Uuid` is parsed once in `From<JwtClaims>` (amortized over the cache
TTL); the middleware reads a `Copy`. 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-parsed `sub_id` equals a fresh
parse.
- **[A4] Drop the `HeaderMap` clone.** Both `auth_middleware` (JWT/Basic/cookie
— all `/api`, WebDAV, CalDAV, CardDAV) and the NextCloud
`basic_auth_middleware` took axum's `HeaderMap` extractor, i.e. a full
`parts.headers.clone()` (~2 allocs) per request, purely to *read* the
Authorization/Cookie headers. Removed; the middleware borrows
`request.headers()` directly. This is a borrow restructuring, not a logic
change: the header borrow is dead (NLL) by the time each arm reaches
`request.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 `getlastmodified` stack render.** The CalDAV read-emitters
(`write_report_page` → event props, `write_collection_event_page`, and the
two per-calendar prop writers) formatted `updated_at.to_rfc2822()` into a
fresh heap `String` per event — up to `CALDAV_STREAM_PAGE_EVENTS = 500` per
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 renderer
`common::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 shared `write_lastmodified_text` helper at all five
sites: 5 → 0 allocs. Gate: stack render byte-identical to `to_rfc2822`.
- **[A6] CalDAV per-event `href` + `etag` reused buffers.** Same emitters
allocated a fresh `format!("{}{}.ics", …)` href and a `format!("\"{}\"", id)`
quoted etag `String` **per event**. The CardDAV emitter already reuses a
single page buffer (`clear()` + `write!`). Ported: `write_report_page` /
`write_collection_event_page` hold reusable `href` + `etag` buffers threaded
through `write_event_response` into 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-event `format!`.
## [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 ubiquitous `t('k', 'Fallback')` and
bare `t('k')` (default `= {}`) allocated a throwaway params object on every
call, though for a cache-hit string with no `{{…}}` `interpolate` returns
before reading params. `t()` runs ~10×/row. Hoisted one frozen
`EMPTY_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 `favoriteIds` incremental set.** The favorites route derived
`favoriteIds = 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 persistent
`SvelteSet` and `add`s only the fresh page's ids (`clear` on reset, `delete`
on unfavorite) — the shape `recent` already 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_playlists` 1 + N `COUNT(*)`** — one
`SELECT COUNT(*) FROM audio.playlist_items` per playlist (up to 101
round-trips at `limit=100`). Foldable into one `LEFT JOIN … GROUP BY`. It's
the public-gallery path (`include_public` defaults false), so opt-in; queued
with its bench. Its two dead siblings `list_playlists_by_owner` /
`list_shared_with_user` carry the same N+1 with **no live caller** (replaced
by `get_playlists_by_ids` post-ROUND3) — flag for deletion, not optimization.
- **Contact REST listings over-fetch the `vcard` TEXT** — `search_contacts`,
`get_contacts_by_address_book_paginated`, and `get_contacts_in_group` select
the full serialized card (the largest column; multi-KB with an embedded
`PHOTO;ENCODING=b`), but every caller maps to `ContactDto`, which has **no**
`vcard` field. Wants a *lite* row mapper (the non-paginated sibling is shared
with the CardDAV stream, which genuinely needs `vcard`), 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-batch `text_by_hash` map even for unique blobs (dead clone in the
common one-file-per-blob case; hold `Arc<str>` or gate on multiplicity);
(b) calls `text_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_blocking` builds a `SnippetGenerator` even
when there are zero hits** — trivial `if top_docs.is_empty() { return … }`.
- **`exif_service` double-allocates** on Make/Model/GPS-ref
(`display_value().to_string().trim_matches('"').trim().to_string()` — the
intermediate `to_string` is thrown away). Per-image, background.
- **REST calendar-event edit** re-`format!`s the whole `ical_data` body 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)
- **`CachedBlobBackend` re-runs `fs::create_dir_all(prefix)` per cache write**
— unlike `LocalBlobBackend::initialize`, which pre-creates all 256 prefix
dirs; a cached-remote upload pays a redundant `mkdir(EEXIST)+stat` + blocking
dispatch per chunk. Pre-create at init and drop the hot-path call.
- **`CachedBlobBackend` eviction listener `std::fs::remove_file` on 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 to `spawn_blocking` / a drain task.
- **`dedup_service` hash-`String` re-allocations** — `distinct_hashes` rebuilds
a set the streaming loop already had (`session_seen`); `settle_batch` clones
every batch hash to bind the pin query. Alloc-count only (within noise on a
throughput bench); report as such.
- **`encrypted_blob_backend` emits 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.sections` re-buckets the whole accumulated list per page**
(O(N²) across a grouped-view drain; trash is grouped-by-default and its
`bucketOf` does `Date` math per item). The fix is the proven `PhotoTimeline`
incremental-builder pattern (persistent `Map` + append-detection); it's the
flagship follow-up, same class as the ROUND13-deferred "unify all four
listing arms onto one `VirtualRows`".
- **`shared/+page.svelte` rebuilds the full `lanes` tree** per page **and** on
every single grant edit (`raw = [...raw]` to force reactivity); and the
favorites/recent/trash routes re-project `items`/`contextMap` per 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>`, `CurrentUser` build = 1 alloc). The residual `Arc::new`
is structurally required (shared with `NcSession`). Do not re-open.
- **Media hooks' raw blob reads are broken, not merely duplicated** (ROUND13
finding stands): `MediaMetadataService` / `FaceIndexingService` read
`.blobs/{hash}.blob` directly, which only exists for local + unencrypted +
single-chunk blobs — silently no capture-date/GPS/faces for the common case.
Correctness fix (route through `read_blob_bytes`), perf-neutral-to-negative;
a shared-`Bytes` provider is the perf follow-up once it lands.
- **`calendar_event_pg_repository::list_events_by_calendar_paginated`** selects
the stale 13-column shape (omits `recurrence_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`/`_queries` updated for the
`TokenClaims.sub_id` field and the narrowed faces read-back).