From c930f865b0e223acd2cf9d69439635e574b604be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:22:12 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20round=2014=20=E2=80=94=20faces=20narrow?= =?UTF-8?q?=20projection,=20auth=20per-request=20allocs,=20CalDAV=20emit?= =?UTF-8?q?=20buffers,=20frontend=20set=20churn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN --- Cargo.toml | 18 + benches/ROUND14.md | 242 ++++++++++ examples/bench_round10_micro.rs | 1 + examples/bench_round10_queries.rs | 21 +- examples/bench_round14_micro.rs | 450 ++++++++++++++++++ examples/bench_round14_queries.rs | 246 ++++++++++ .../src/lib/components/round14.bench.test.ts | 138 ++++++ frontend/src/lib/i18n/index.svelte.ts | 10 +- frontend/src/routes/favorites/+page.svelte | 14 +- src/application/adapters/caldav_adapter.rs | 93 +++- src/application/ports/auth_ports.rs | 7 + src/application/ports/face_ports.rs | 13 +- src/application/services/people_service.rs | 7 +- src/application/services/search_service.rs | 57 ++- src/domain/entities/face.rs | 13 + .../repositories/pg/face_pg_repository.rs | 35 +- src/infrastructure/services/jwt_service.rs | 7 + src/interfaces/middleware/auth.rs | 37 +- .../nextcloud/basic_auth_middleware.rs | 9 +- 19 files changed, 1345 insertions(+), 73 deletions(-) create mode 100644 benches/ROUND14.md create mode 100644 examples/bench_round14_micro.rs create mode 100644 examples/bench_round14_queries.rs create mode 100644 frontend/src/lib/components/round14.bench.test.ts diff --git a/Cargo.toml b/Cargo.toml index c7c89fb1..7a74f8b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,24 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-14 battery ──────────────────────────────────────────────────────────── + +# Round-14 query-shape pack — lightbox face-box narrow projection (drop the +# 2 KiB embedding BYTEA + 6 unused columns; push the caller filter into SQL), +# music public-playlist N+1 fold, contact-listing vcard over-fetch (needs Postgres). +[[example]] +name = "bench_round14_queries" +path = "examples/bench_round14_queries.rs" +required-features = ["bench"] + +# Round-14 CPU/alloc micro-pack — cookie borrow-only extract, auth HeaderMap +# clone removal, sub→Uuid pre-parse, CalDAV per-event emit (rfc2822 stack +# render + reused href/etag buffers), search ASCII case-fold. No Postgres. +[[example]] +name = "bench_round14_micro" +path = "examples/bench_round14_micro.rs" +required-features = ["bench"] + # Round-13 battery ──────────────────────────────────────────────────────────── # Round-13 HTTP micro-pack — duplicate /api TraceLayer removal, borrow-only diff --git a/benches/ROUND14.md b/benches/ROUND14.md new file mode 100644 index 00000000..02e7495a --- /dev/null +++ b/benches/ROUND14.md @@ -0,0 +1,242 @@ +# 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` 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`), so the parse repeated on ~all-hit steady state. A new + `sub_id: Uuid` is parsed once in `From` (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` 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`** — the ROUND6/ROUND9-deferred "cheapest known win on + the /api path" was **already shipped in ROUND10** (`TokenClaims.username`/ + `email: Arc`, `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). diff --git a/examples/bench_round10_micro.rs b/examples/bench_round10_micro.rs index 0cdb12b2..6e09eb41 100644 --- a/examples/bench_round10_micro.rs +++ b/examples/bench_round10_micro.rs @@ -151,6 +151,7 @@ fn section_identity(iters: u64) { }); let new_claims = Arc::new(TokenClaims { sub: "6a11f8a2-14a5-4f8a-9d55-3e3c8a2b9a01".into(), + sub_id: uuid::Uuid::parse_str("6a11f8a2-14a5-4f8a-9d55-3e3c8a2b9a01").unwrap(), exp: 4_102_444_800, iat: 1_700_000_000, jti: uuid::Uuid::nil().to_string(), diff --git a/examples/bench_round10_queries.rs b/examples/bench_round10_queries.rs index f7901a7d..32ec08c3 100644 --- a/examples/bench_round10_queries.rs +++ b/examples/bench_round10_queries.rs @@ -707,15 +707,22 @@ async fn section_save_faces(pool: &Arc, passes: usize) { }) .await; - // Gate: batch write round-trips identically (row content check). + // Gate: batch write round-trips identically (row content check). Read the + // probe row back with a direct full-column SELECT — the repo's narrow + // `face_boxes_for_file` (ROUND14 §Q1) no longer returns embedding/quality/ + // blob_hash, so this section fetches them itself to keep the gate intact. let probe = make_faces(3); repo.save_faces(&probe).await.unwrap(); - let stored = repo.faces_for_file(s.file).await.unwrap(); - let got = stored.iter().find(|f| f.id == probe[1].id).expect("stored"); - assert_eq!(got.bbox.to_array(), probe[1].bbox.to_array()); - assert_eq!(got.embedding.len(), probe[1].embedding.len()); - assert_eq!(got.quality, probe[1].quality); - assert_eq!(got.blob_hash, probe[1].blob_hash); + let (bbox, embedding, quality, blob_hash): (Vec, Vec, Option, Option) = + sqlx::query_as("SELECT bbox, embedding, quality, blob_hash FROM faces.faces WHERE id = $1") + .bind(probe[1].id) + .fetch_one(pool.as_ref()) + .await + .expect("stored"); + assert_eq!(bbox, probe[1].bbox.to_array()); + assert_eq!(embedding.len() / 4, probe[1].embedding.len()); + assert_eq!(quality, probe[1].quality); + assert_eq!(blob_hash, probe[1].blob_hash); println!( " 30-face image: BEFORE loop {before_ms:.3} ms → AFTER UNNEST {after_ms:.3} ms ({:.1}x)", diff --git a/examples/bench_round14_micro.rs b/examples/bench_round14_micro.rs new file mode 100644 index 00000000..77faa330 --- /dev/null +++ b/examples/bench_round14_micro.rs @@ -0,0 +1,450 @@ +//! Round-14 CPU/alloc micro-pack (no Postgres). +//! +//! Each section is BEFORE (verbatim replica of the shipped shape, or the +//! shipped function itself) vs AFTER (proposed shape), with a byte-identity / +//! equivalence gate and a `GATE FAIL … rollback` check that exits non-zero if +//! the AFTER arm fails to beat its BEFORE — the round's roll-back rule encoded +//! into the benchmark. +//! +//! [A1] Cookie auth extract — `extract_cookie_value` (owned `String`, only +//! reborrowed as `&str` into `validate_token`) vs the borrow-only +//! `extract_cookie_str` that already backs the CSRF middleware. +//! [A2] Search `compute_relevance` — `name.to_lowercase()` per result row +//! vs an ASCII case-fold fast path (Unicode fallback preserved). +//! [A3] Auth middleware `sub` → `Uuid` — re-parsed from the 36-char claim on +//! every authenticated request vs a pre-parsed `Uuid` (Copy) carried on +//! the cached claims. +//! [A4] Auth middleware `HeaderMap` clone — the `headers: HeaderMap` +//! extractor duplicates the whole map per request though every use is a +//! read `request.headers()` already exposes. +//! [A5] CalDAV getlastmodified — `updated_at.to_rfc2822()` (heap `String` +//! per event) vs the stack `common::fmt::rfc2822_utc` the CardDAV +//! emitter already uses. +//! [A6] CalDAV per-event href + quoted etag — a fresh `format!` `String` +//! pair per event vs a reused page buffer (`clear()` + `write!`), the +//! shape the CardDAV report emitter already ships. +//! +//! Run: +//! cargo run --release --features bench --example bench_round14_micro +//! Tunables (env): BENCH_ITERS (200000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::fmt::Write as _; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::http::{HeaderMap, HeaderValue, header}; +use uuid::Uuid; + +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!("| {:<40} | {:>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 + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A1] Cookie auth extract — owned String vs borrow-only &str +// ──────────────────────────────────────────────────────────────────────────── + +fn section_cookie() { + use oxicloud::interfaces::api::cookie_auth::{extract_cookie_str, extract_cookie_value}; + + let iters: usize = env_or("BENCH_ITERS", 200_000); + let name = "oxicloud_access"; + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMTIzNDU2Nzg5YWJjZGVmIn0.c2lnbmF0dXJlLXBsYWNlaG9sZGVy"; + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("{name}={jwt}; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301")) + .unwrap(), + ); + + // Gate: byte-identical value. + let owned = extract_cookie_value(&headers, name); + let borrowed = extract_cookie_str(&headers, name); + assert_eq!(owned.as_deref(), borrowed, "cookie value differs"); + assert_eq!(borrowed, Some(jwt), "unexpected cookie value"); + println!("# [A1] gate: borrow-only extract byte-identical to owned — OK"); + + let m_before = measure(iters, || { + let v = extract_cookie_value(black_box(&headers), name); + black_box(v); + }); + let m_after = measure(iters, || { + let v = extract_cookie_str(black_box(&headers), name); + black_box(v); + }); + + println!("\n## [A1] Cookie access-token extract (per cookie-authed /api request)"); + header_footer("extract owned/borrow", &m_before, &m_after); + if m_after.allocs_per_op >= m_before.allocs_per_op { + eprintln!("GATE FAIL [A1]: borrow arm did not remove an allocation — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A2] Search compute_relevance — Unicode lowercase vs ASCII fast path +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE — verbatim `search_service::compute_relevance`. +fn relevance_before(name: &str, query_lower: &str) -> u32 { + let name_lower = name.to_lowercase(); + if name_lower == query_lower { + 100 + } else if name_lower.starts_with(query_lower) { + 80 + } else if name_lower.contains(query_lower) { + let ratio = query_lower.len() as f64 / name_lower.len() as f64; + 50 + (ratio * 20.0) as u32 + } else { + 0 + } +} + +#[inline] +fn ascii_ci_starts_with(h: &[u8], n: &[u8]) -> bool { + h.len() >= n.len() && h[..n.len()].eq_ignore_ascii_case(n) +} +#[inline] +fn ascii_ci_contains(h: &[u8], n: &[u8]) -> bool { + if n.is_empty() { + return true; + } + if n.len() > h.len() { + return false; + } + h.windows(n.len()).any(|w| w.eq_ignore_ascii_case(n)) +} + +/// AFTER — ASCII fast path (Unicode fallback preserves exact behavior). +fn relevance_after(name: &str, query_lower: &str) -> u32 { + if name.is_ascii() { + let nb = name.as_bytes(); + let qb = query_lower.as_bytes(); + if nb.eq_ignore_ascii_case(qb) { + 100 + } else if ascii_ci_starts_with(nb, qb) { + 80 + } else if ascii_ci_contains(nb, qb) { + let ratio = query_lower.len() as f64 / name.len() as f64; + 50 + (ratio * 20.0) as u32 + } else { + 0 + } + } else { + relevance_before(name, query_lower) + } +} + +fn section_relevance() { + let iters: usize = env_or("BENCH_ITERS", 200_000) / 4; + + // Mixed corpus: exact / prefix / substring / miss, ASCII and non-ASCII + // names, ASCII and non-ASCII (already-lowercased) queries. + let corpus: &[(&str, &str)] = &[ + ("Report.pdf", "report.pdf"), + ("Report.pdf", "report"), + ("Annual Report 2026.pdf", "report"), + ("Vacation Photo.jpg", "xyz"), + ("Hello World.txt", "world"), + ("IMG_20260719_120000.HEIC", "img"), + ("Résumé Final.pdf", "resume"), + ("Café Menu.txt", "café"), + ("STRASSE.txt", "straße"), + ("naïve-approach.md", "naïve"), + ("Notes.md", "note"), + ("budget-Q3.xlsx", "q3"), + ]; + + // Gate: AFTER == BEFORE for every corpus entry. + for (name, q) in corpus { + assert_eq!( + relevance_before(name, q), + relevance_after(name, q), + "relevance differs for ({name:?}, {q:?})" + ); + } + println!("# [A2] gate: ASCII fast path matches Unicode lowercase across {} cases — OK", corpus.len()); + + let m_before = measure(iters, || { + for (name, q) in corpus { + black_box(relevance_before(black_box(name), black_box(q))); + } + }); + let m_after = measure(iters, || { + for (name, q) in corpus { + black_box(relevance_after(black_box(name), black_box(q))); + } + }); + + println!("\n## [A2] compute_relevance over a {}-row result page (per search / keystroke)", corpus.len()); + header_footer("relevance whole corpus", &m_before, &m_after); + if m_after.wall_ns_per_op >= m_before.wall_ns_per_op { + eprintln!("GATE FAIL [A2]: ASCII fast path not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A3] Auth middleware sub → Uuid — re-parse per request vs pre-parsed Copy +// ──────────────────────────────────────────────────────────────────────────── + +fn section_sub_parse() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let sub = "0123abcd-4f89-41d3-9a0c-0305e82c3301".to_string(); + let pre_parsed = Uuid::parse_str(&sub).unwrap(); + + // Gate: the pre-parsed uuid equals a fresh parse. + assert_eq!(Uuid::parse_str(&sub).unwrap(), pre_parsed, "uuid parse differs"); + println!("# [A3] gate: pre-parsed sub_id equals per-request parse — OK"); + + let m_before = measure(iters, || { + let u = Uuid::parse_str(black_box(&sub)).unwrap(); + black_box(u); + }); + let m_after = measure(iters, || { + let u = black_box(pre_parsed); // Copy of the pre-parsed Uuid + black_box(u); + }); + + println!("\n## [A3] sub → Uuid on the authed request path (Bearer + cookie)"); + header_footer("sub parse/copy", &m_before, &m_after); + if m_after.wall_ns_per_op >= m_before.wall_ns_per_op { + eprintln!("GATE FAIL [A3]: pre-parsed copy not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A4] Auth middleware HeaderMap clone — clone-the-map vs borrow + get +// ──────────────────────────────────────────────────────────────────────────── + +fn build_request_headers() -> HeaderMap { + // A representative authed browser request. + let mut h = HeaderMap::new(); + h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig")); + h.insert( + header::COOKIE, + HeaderValue::from_static("oxicloud_access=eyJ.payload.sig; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301"), + ); + h.insert(header::HOST, HeaderValue::from_static("cloud.example.com")); + h.insert(header::USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")); + h.insert(header::ACCEPT, HeaderValue::from_static("application/json, text/plain, */*")); + h.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate, br")); + h.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); + h.insert(header::REFERER, HeaderValue::from_static("https://cloud.example.com/files")); + h.insert("x-csrf-token", HeaderValue::from_static("3f2504e0-4f89-41d3-9a0c-0305e82c3301")); + h.insert(header::CONNECTION, HeaderValue::from_static("keep-alive")); + h +} + +fn section_headermap_clone() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let headers = build_request_headers(); + + // Gate: the token extracted from a cloned map equals that from the borrowed map. + let from_clone = { + let c = headers.clone(); + c.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).map(str::to_string) + }; + let from_borrow = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()); + assert_eq!(from_clone.as_deref(), from_borrow, "authorization differs"); + println!("# [A4] gate: token from cloned map == token from borrowed map — OK"); + + let m_before = measure(iters, || { + // BEFORE: the `headers: HeaderMap` extractor clones the whole map, + // then the middleware only reads from it. + let cloned = black_box(&headers).clone(); + let tok = cloned.get(header::AUTHORIZATION); + black_box(tok); + black_box(cloned); + }); + let m_after = measure(iters, || { + // AFTER: read straight from the borrowed request headers. + let tok = black_box(&headers).get(header::AUTHORIZATION); + black_box(tok); + }); + + println!("\n## [A4] Auth middleware HeaderMap (per authed /api + DAV + NC request)"); + header_footer("headers clone/borrow", &m_before, &m_after); + if m_after.allocs_per_op >= m_before.allocs_per_op { + eprintln!("GATE FAIL [A4]: borrow arm did not remove allocations — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A5] CalDAV getlastmodified — chrono to_rfc2822 String vs stack rfc2822_utc +// ──────────────────────────────────────────────────────────────────────────── + +fn section_caldav_rfc2822() { + use chrono::{DateTime, Utc}; + use oxicloud::common::fmt::rfc2822_utc; + + let iters: usize = env_or("BENCH_ITERS", 200_000); + // A spread of realistic event updated_at timestamps. + let secs: &[i64] = &[ + 1_752_752_834, // 2025-07-17 … + 0, // Thu, 1 Jan 1970 (day not zero-padded — the parity edge) + 1_600_000_000, + 1_262_304_000, + 253_402_300_799, // 9999-12-31 23:59:59 (max 4-digit year) + ]; + + // Gate: rfc2822_utc byte-identical to chrono to_rfc2822 for every sample. + for &s in secs { + let dt = DateTime::::from_timestamp(s, 0).unwrap(); + let chrono_s = dt.to_rfc2822(); + let mut buf = [0u8; 31]; + let stack_s = rfc2822_utc(&mut buf, s).expect("in range"); + assert_eq!(chrono_s, stack_s, "rfc2822 differs for secs={s}"); + } + println!("# [A5] gate: stack rfc2822_utc byte-identical to chrono to_rfc2822 — OK"); + + let dts: Vec> = secs.iter().map(|&s| DateTime::::from_timestamp(s, 0).unwrap()).collect(); + + let m_before = measure(iters, || { + for dt in &dts { + black_box(black_box(dt).to_rfc2822()); + } + }); + let m_after = measure(iters, || { + for &s in secs { + let mut buf = [0u8; 31]; + black_box(rfc2822_utc(&mut buf, black_box(s))); + } + }); + + println!("\n## [A5] CalDAV getlastmodified render ({} events, per REPORT/PROPFIND)", secs.len()); + header_footer("rfc2822 chrono/stack", &m_before, &m_after); + if m_after.allocs_per_op >= m_before.allocs_per_op { + eprintln!("GATE FAIL [A5]: stack render did not remove allocations — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [A6] CalDAV per-event href + quoted etag — fresh format! vs reused buffer +// ──────────────────────────────────────────────────────────────────────────── + +fn section_caldav_href_etag() { + let iters: usize = env_or("BENCH_ITERS", 200_000) / 20; + let base_href = "/caldav/alice/personal/"; + // A page of events (uid, id) like write_report_page iterates. + let events: Vec<(String, Uuid)> = (0..40) + .map(|i| (format!("event-uid-{i:04}-abcdef@oxicloud"), Uuid::from_u128(0x1000 + i as u128))) + .collect(); + + // Gate: reused-buffer output identical to the per-event format! pair. + for (uid, id) in &events { + let href_fmt = format!("{base_href}{uid}.ics"); + let etag_fmt = format!("\"{id}\""); + let mut href_buf = String::new(); + let mut etag_buf = String::new(); + write!(href_buf, "{base_href}{uid}.ics").unwrap(); + write!(etag_buf, "\"{id}\"").unwrap(); + assert_eq!(href_fmt, href_buf, "href differs"); + assert_eq!(etag_fmt, etag_buf, "etag differs"); + } + println!("# [A6] gate: reused-buffer href/etag identical to per-event format! — OK"); + + let m_before = measure(iters, || { + // BEFORE: two fresh String allocations per event. + for (uid, id) in &events { + let href = format!("{base_href}{uid}.ics"); + let etag = format!("\"{id}\""); + black_box((href, etag)); + } + }); + let m_after = measure(iters, || { + // AFTER: one reusable href buffer + one etag buffer for the whole page. + let mut href = String::new(); + let mut etag = String::new(); + for (uid, id) in &events { + href.clear(); + etag.clear(); + let _ = write!(href, "{base_href}{uid}.ics"); + let _ = write!(etag, "\"{id}\""); + black_box((&href, &etag)); + } + }); + + println!("\n## [A6] CalDAV per-event href + etag ({} events/page, per REPORT/PROPFIND)", events.len()); + header_footer("href+etag per page", &m_before, &m_after); + if m_after.allocs_per_op >= m_before.allocs_per_op { + eprintln!("GATE FAIL [A6]: reused buffer did not reduce allocations — rollback"); + std::process::exit(1); + } +} + +fn main() { + println!("#################################################################"); + println!("# Round-14 CPU/alloc micro-pack"); + println!("#################################################################\n"); + + section_cookie(); + section_relevance(); + section_sub_parse(); + section_headermap_clone(); + section_caldav_rfc2822(); + section_caldav_href_etag(); + + println!("\nGATE PASS (all sections)"); +} diff --git a/examples/bench_round14_queries.rs b/examples/bench_round14_queries.rs new file mode 100644 index 00000000..0fa7fa35 --- /dev/null +++ b/examples/bench_round14_queries.rs @@ -0,0 +1,246 @@ +//! Round-14 query-shape pack (needs the dev Postgres up; reads DATABASE_URL +//! from `.env`). +//! +//! Each section is BEFORE (verbatim replica of the shipped query shape) vs +//! AFTER (proposed shape), with an equivalence/safety gate and a `GATE FAIL` +//! rollback check — an AFTER that doesn't beat its BEFORE exits non-zero. +//! +//! [Q1] Lightbox face boxes — `faces_for_file`'s 10-column row (incl. the +//! 2,048-byte `embedding` BYTEA, decoded into a `Vec` per face) +//! hydrated for a group photo, then filtered `user_id == caller` in +//! Rust, vs a narrow `SELECT id, person_id, bbox … WHERE file_id = $1 +//! AND user_id = $2` (embedding + 6 unused columns dropped; the caller +//! filter pushed into SQL). The only consumer, `people_service:: +//! faces_for_file`, builds `FaceBoxDto { id, person_id, x,y,w,h }`. +//! +//! Run: +//! cargo run --release --features bench --example bench_round14_queries +//! Tunables (env): BENCH_PASSES (200), BENCH_FACES_PER_FILE (15) + +use std::env; +use std::time::Instant; + +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn stats(mut s: Vec) -> (f64, f64, f64) { + s.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = s.len(); + ( + s.iter().sum::() / n as f64, + s[n / 2], + s[((n as f64 * 0.95) as usize).min(n - 1)], + ) +} + +/// Mirror of `face_pg_repository::bytes_to_embedding` — the per-face +/// `Vec` decode the BEFORE path pays for a column it never reads. +fn bytes_to_embedding(b: &[u8]) -> Vec { + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +// ──────────────────────────────────────────────────────────────────────────── +// [Q1] Lightbox face boxes — wide row (incl. embedding) vs narrow projection +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE, verbatim `faces_for_file` + `people_service::faces_for_file`: +/// hydrate the full 10-column row (decoding the 2 KiB embedding like +/// `row_to_face`), then filter `user_id == caller` in Rust and keep only +/// `(id, person_id, bbox)`. +async fn boxes_before(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option, Vec)> { + let rows = sqlx::query( + "SELECT id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at + FROM faces.faces WHERE file_id = $1", + ) + .bind(file_id) + .fetch_all(pool) + .await + .expect("faces wide"); + rows.into_iter() + .filter_map(|r| { + let user_id: Uuid = r.get("user_id"); + // Decode the embedding exactly as `row_to_face` does (the cost the + // BEFORE path pays even though `FaceBoxDto` never reads it). + let emb_bytes: Vec = r.get("embedding"); + let _embedding = bytes_to_embedding(&emb_bytes); + if user_id != caller { + return None; + } + let bbox: Vec = r.get("bbox"); + Some((r.get("id"), r.get("person_id"), bbox)) + }) + .collect() +} + +/// AFTER: narrow projection, caller filter in SQL. +async fn boxes_after(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option, Vec)> { + let rows = sqlx::query( + "SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2", + ) + .bind(file_id) + .bind(caller) + .fetch_all(pool) + .await + .expect("faces narrow"); + rows.into_iter() + .map(|r| { + let bbox: Vec = r.get("bbox"); + (r.get("id"), r.get("person_id"), bbox) + }) + .collect() +} + +async fn section_face_boxes(pool: &PgPool) { + let n: usize = env_or("BENCH_FACES_PER_FILE", 15); + let passes: usize = env_or("BENCH_PASSES", 200); + + // Seed: user + drive + folder + one photo file + N faces on it. + let mut tx = pool.begin().await.expect("begin"); + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench14_faces', 'bench14_faces@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("user"); + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, default_for_user) VALUES ('personal', $1) RETURNING id", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await + .expect("drive"); + let folder_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('bench14', '/bench14', 'bench14', $1) RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + let file_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id) + VALUES ('group.jpg', $1, 'bench14blob00000000000000000000000000000000000000000000000000', 1024, 'image/jpeg', $2) + RETURNING id", + ) + .bind(folder_id) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("file"); + tx.commit().await.expect("commit"); + + let person_id: Uuid = sqlx::query_scalar( + "INSERT INTO faces.persons (user_id, display_name) VALUES ($1, 'P') RETURNING id", + ) + .bind(user_id) + .fetch_one(pool) + .await + .expect("person"); + + let embedding = vec![7u8; 2048]; // 512 × f32, like the real thing + for i in 0..n { + // Half the faces are named, half unassigned — exercises Option. + let pid = if i % 2 == 0 { Some(person_id) } else { None }; + sqlx::query( + "INSERT INTO faces.faces + (file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash) + VALUES ($1, $2, $3, ARRAY[0.1,0.2,0.3,0.4]::real[], 0.99, 0.9, $4, NULL)", + ) + .bind(file_id) + .bind(user_id) + .bind(pid) + .bind(&embedding) + .execute(pool) + .await + .expect("face"); + } + sqlx::query("ANALYZE faces.faces").execute(pool).await.ok(); + + // Equivalence gate: same (id, person_id, bbox) set both ways, all N present. + let mut b = boxes_before(pool, file_id, user_id).await; + let mut a = boxes_after(pool, file_id, user_id).await; + b.sort_by(|x, y| x.0.cmp(&y.0)); + a.sort_by(|x, y| x.0.cmp(&y.0)); + assert_eq!(b, a, "face-box projections differ"); + assert_eq!(a.len(), n, "expected all faces"); + println!("# [Q1] gate: wide/narrow face-box sets identical ({n} faces) — OK"); + + let mut wide = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(boxes_before(pool, file_id, user_id).await); + wide.push(t.elapsed().as_secs_f64() * 1e3); + } + let mut narrow = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(boxes_after(pool, file_id, user_id).await); + narrow.push(t.elapsed().as_secs_f64() * 1e3); + } + let (wm, wp50, wp95) = stats(wide); + let (nm, np50, np95) = stats(narrow); + let wire_before = n * (2048 + 16 * 4 + 24); // embedding + uuids/bbox + row overhead + let wire_after = n * (16 + 16 + 16 + 8); + println!("\n## [Q1] Lightbox face boxes — group photo, {n} faces"); + println!("| arm | mean ms | p50 ms | p95 ms | ~bytes/req |"); + println!("| BEFORE wide row (incl. embedding) | {wm:>7.3} | {wp50:>6.3} | {wp95:>6.3} | {wire_before:>9} |"); + println!("| AFTER narrow (id,person,bbox) | {nm:>7.3} | {np50:>6.3} | {np95:>6.3} | {wire_after:>9} |"); + println!( + "# {:.2}x faster; ~{} KiB embedding/columns off the wire per lightbox open (scales with face count)", + wm / nm, + (wire_before - wire_after) / 1024 + ); + + // Cleanup (cascades faces + persons via FKs on drive/user delete). + sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(drive_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(user_id) + .execute(pool) + .await + .ok(); + + if nm >= wm { + eprintln!("GATE FAIL [Q1]: narrow projection not faster — rollback"); + std::process::exit(1); + } +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + let _ = dotenvy::dotenv(); + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required (see .env)"); + let pool = PgPoolOptions::new() + .max_connections(8) + .connect(&url) + .await + .expect("connect"); + + println!("#################################################################"); + println!("# Round-14 query-shape pack"); + println!("#################################################################"); + + section_face_boxes(&pool).await; + + println!("\nGATE PASS (all sections)"); +} diff --git a/frontend/src/lib/components/round14.bench.test.ts b/frontend/src/lib/components/round14.bench.test.ts new file mode 100644 index 00000000..076ee744 --- /dev/null +++ b/frontend/src/lib/components/round14.bench.test.ts @@ -0,0 +1,138 @@ +// Round-14 frontend micro-pack (benches/ROUND14.md §F1, §F2). +// +// Each section is BEFORE (verbatim replica of the shipped shape) vs AFTER +// (proposed shape), with an equivalence gate and a wall-time perf gate — the +// same discipline as the Rust micro-packs: an AFTER that doesn't beat its +// BEFORE fails the gate. + +import { describe, expect, it } from 'vitest'; + +// ──────────────────────────────────────────────────────────────────────────── +// [F2] favorites `favoriteIds` — rebuild-a-fresh-Set-per-page vs incremental +// ──────────────────────────────────────────────────────────────────────────── +// +// Audit finding: 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 P-page drain — and, being a new instance each page, +// invalidated every mounted star reader. The fix keeps one persistent set and +// `add`s only the fresh page's ids (clear on reset). Since every item on the +// page is a favorite and removed items aren't rendered, the set only has to be +// a superset of the displayed ids, so `add`-only is correct. + +/** A page of ids (50/page, the default page size). */ +function pageOf(start: number, n: number): string[] { + return Array.from({ length: n }, (_, i) => `fav-${start + i}`); +} + +/** BEFORE: rebuild a fresh Set over the whole accumulated list each page. */ +function rebuildPerPage(pages: string[][]): Set { + let acc: string[] = []; + let set = new Set(); + for (const page of pages) { + acc = [...acc, ...page]; // the route's `raw = [...raw, ...page]` + set = new Set(acc.map((id) => id)); // new instance + O(N) rebuild + } + return set; +} + +/** AFTER: one persistent set, add only the fresh page's ids. */ +function incrementalPerPage(pages: string[][]): Set { + const set = new Set(); + for (const page of pages) { + for (const id of page) set.add(id); + } + return set; +} + +describe('round14 §F2 — favorites favoriteIds incremental set', () => { + it('final membership is identical (equivalence gate)', () => { + const pages = Array.from({ length: 20 }, (_, p) => pageOf(p * 50, 50)); + const before = rebuildPerPage(pages); + const after = incrementalPerPage(pages); + expect(after.size).toBe(before.size); + for (const id of before) expect(after.has(id)).toBe(true); + for (const id of after) expect(before.has(id)).toBe(true); + }); + + it('a P-page drain builds the set ≥5x faster incrementally (perf gate)', () => { + const PAGES = 40; + const PER = 50; // 2 000 items total + const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER)); + + const run = (f: (p: string[][]) => Set): number => { + const t0 = performance.now(); + // A few repetitions so the measurement isn't dominated by timer noise. + for (let r = 0; r < 20; r++) f(pages); + return performance.now() - t0; + }; + + // Warm-up (JIT) then measure. + run(rebuildPerPage); + run(incrementalPerPage); + const beforeMs = run(rebuildPerPage); + const afterMs = run(incrementalPerPage); + + console.info( + `§F2 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)` + ); + expect(afterMs).toBeLessThan(beforeMs / 5); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// [F1] t() params — throwaway `{}` per call vs a shared frozen empty object +// ──────────────────────────────────────────────────────────────────────────── +// +// The ubiquitous inline-fallback form `t('k', 'Fallback')` and the bare +// `t('k')` (default param `= {}`) allocated a fresh params object on every +// call, though for a cache-hit string with no `{{…}}` `interpolate` returns +// before ever reading params. t() runs ~10×/row. The fix hoists a shared +// frozen `EMPTY_PARAMS` for both no-param branches. + +const EMPTY_PARAMS: Record = Object.freeze({}); + +/** Model of the shipped t() param selection + a representative params read + * (interpolate's `params[name]` lookup), isolated from dictionary I/O. */ +function tBefore(paramsOrFallback: string | Record = {}): unknown { + const isStringForm = typeof paramsOrFallback === 'string'; + const params = isStringForm ? {} : paramsOrFallback; + return (params as Record)['n']; +} +function tAfter(paramsOrFallback: string | Record = EMPTY_PARAMS): unknown { + const isStringForm = typeof paramsOrFallback === 'string'; + const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback; + return (params as Record)['n']; +} + +describe('round14 §F1 — t() shared empty params', () => { + it('produces identical results for the no-param call forms (equivalence gate)', () => { + expect(tAfter()).toBe(tBefore()); + expect(tAfter('Owner')).toBe(tBefore('Owner')); + expect(tAfter({ n: 5 })).toBe(tBefore({ n: 5 })); + }); + + it('the string/bare forms are not slower with a shared empty (perf gate)', () => { + const N = 4_000_000; + const run = (f: (a?: string | Record) => unknown): number => { + let sink: unknown; + const t0 = performance.now(); + for (let i = 0; i < N; i++) { + // Alternate the two no-param call forms (bare + string fallback). + sink = i & 1 ? f('Fallback') : f(); + } + void sink; + return performance.now() - t0; + }; + // Warm-up then measure (best-of-3 to damp GC/JIT noise). + run(tBefore); + run(tAfter); + const beforeMs = Math.min(run(tBefore), run(tBefore), run(tBefore)); + const afterMs = Math.min(run(tAfter), run(tAfter), run(tAfter)); + console.info( + `§F1 ${N} no-param t() calls: fresh {} ${beforeMs.toFixed(1)} ms vs shared frozen ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)` + ); + // Zero-risk alloc reduction: the shared-empty arm must be no slower. + expect(afterMs).toBeLessThanOrEqual(beforeMs * 1.05); + }); +}); diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts index 52fd2e75..47c21c64 100644 --- a/frontend/src/lib/i18n/index.svelte.ts +++ b/frontend/src/lib/i18n/index.svelte.ts @@ -201,6 +201,12 @@ async function loadDict(locale: string): Promise { return dicts[locale]; } +/** Shared frozen empty params for the no-interpolation call forms, so the + * ubiquitous `t(key)` / `t(key, 'fallback')` don't each allocate a throwaway + * `{}` (t() is the hottest UI function — ~10× per row). Never mutated, so a + * single shared instance is safe. See benches/ROUND14.md §F1. */ +const EMPTY_PARAMS: Record = Object.freeze({}); + /** * Translate a key. * - `t(key)` / `t(key, params)` — interpolation params object. @@ -209,11 +215,11 @@ async function loadDict(locale: string): Promise { */ export function t( key: string, - paramsOrFallback: string | Record = {}, + paramsOrFallback: string | Record = EMPTY_PARAMS, fallbackArg?: string ): string { const isStringForm = typeof paramsOrFallback === 'string'; - const params = isStringForm ? {} : paramsOrFallback; + const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback; const fallback = isStringForm ? paramsOrFallback : (fallbackArg ?? null); const localeData = dicts[store.locale]; diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index c236a4d4..a52aa3f1 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -57,7 +57,14 @@ raw.map((it) => [it.resource.id, { date: it.favorited_at } satisfies ItemContext]) ) ); - const favoriteIds = $derived(new SvelteSet(items.map((i) => i.id))); + // Persistent reactive set, updated in place per page (add the fresh page's + // ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the + // whole accumulated list on every infinite-scroll page — that was O(N²) + // across a drain and, being a new instance each page, invalidated every + // mounted star reader. Every item on this page is a favorite, and removed + // items are no longer rendered, so the set only needs to be a superset of + // the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape). + const favoriteIds = new SvelteSet(); const groupBys: GroupByDef[] = [ { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, @@ -106,6 +113,10 @@ resourceTypes: ['file', 'folder'] }); raw = reset ? page.items : [...raw, ...page.items]; + // Keep the persistent favoriteIds set in sync incrementally: clear on + // reset, then add only this page's ids (benches/ROUND14.md §F2). + if (reset) favoriteIds.clear(); + for (const it of page.items) favoriteIds.add(it.resource.id); cursor = page.next_cursor; void owners.resolve(page.items.map((i) => i.resource.created_by)); } catch (e) { @@ -148,6 +159,7 @@ try { await removeFavorite(kind, item.id); raw = raw.filter((i) => i.resource.id !== item.id); + favoriteIds.delete(item.id); } catch (e) { errorToast(e); } diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index 7f26c828..421902e1 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -789,11 +789,9 @@ impl CalDavAdapter { xml_writer.write_event(Event::Text(BytesText::new(&calendar.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - // Last modified + // Last modified (stack render, benches/ROUND14.md §A5) xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new( - &calendar.updated_at.to_rfc2822(), - )))?; + Self::write_lastmodified_text(xml_writer, calendar.updated_at)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; // ETag @@ -920,9 +918,8 @@ impl CalDavAdapter { } ("DAV:", "getlastmodified") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new( - &calendar.updated_at.to_rfc2822(), - )))?; + // Stack render (benches/ROUND14.md §A5). + Self::write_lastmodified_text(xml_writer, calendar.updated_at)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; } ("DAV:", "getetag") => { @@ -1097,11 +1094,34 @@ impl CalDavAdapter { /// response per DB row made clients dedupe the shared href and the /// exception appeared to vanish. Callers guarantee same-UID rows /// arrive within a single page. + /// Emit an RFC 2822 `getlastmodified` text node with the allocation-free + /// stack renderer (byte-identical to `chrono::to_rfc2822` — the parity + /// gate lives in `common::fmt`), falling back to chrono only for + /// out-of-4-digit-year timestamps. Mirrors the CardDAV emitter; replaces + /// the per-event `updated_at.to_rfc2822()` heap `String` + /// (benches/ROUND14.md §A5). + fn write_lastmodified_text( + xml_writer: &mut Writer, + ts: DateTime, + ) -> Result<()> { + let mut buf = [0u8; 31]; + match crate::common::fmt::rfc2822_utc(&mut buf, ts.timestamp()) { + Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?, + None => xml_writer.write_event(Event::Text(BytesText::new(&ts.to_rfc2822())))?, + } + Ok(()) + } + pub fn write_collection_event_page( xml_writer: &mut Writer, events: &[CalendarEventDto], base_href: &str, ) -> Result<()> { + // Reused per-event buffers (cleared each iteration) so a whole PROPFIND + // page allocates the href/etag storage once instead of twice per event + // (benches/ROUND14.md §A6). + let mut event_href = String::with_capacity(base_href.len() + 48); + let mut etag = String::new(); for bundle in group_events_by_uid(events) { // The master (sorted first by group_events_by_uid) // supplies the ETag anchor + getlastmodified. If @@ -1111,7 +1131,11 @@ impl CalDavAdapter { Some(e) => *e, None => continue, }; - let event_href = format!("{}{}.ics", base_href, anchor.ical_uid); + event_href.clear(); + let _ = std::fmt::Write::write_fmt( + &mut event_href, + format_args!("{}{}.ics", base_href, anchor.ical_uid), + ); xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; @@ -1124,9 +1148,11 @@ impl CalDavAdapter { // resourcetype (empty for non-collection) xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; - // getetag — anchor row's id + // getetag — anchor row's id (reused buffer, benches/ROUND14.md §A6) xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?; + etag.clear(); + let _ = std::fmt::Write::write_fmt(&mut etag, format_args!("\"{}\"", anchor.id)); + xml_writer.write_event(Event::Text(BytesText::new(&etag)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; // getcontenttype @@ -1136,9 +1162,9 @@ impl CalDavAdapter { )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - // getlastmodified — anchor row's updated_at + // getlastmodified — anchor row's updated_at (stack render, §A5) xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?; + Self::write_lastmodified_text(xml_writer, anchor.updated_at)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; @@ -1189,13 +1215,21 @@ impl CalDavAdapter { CalDavReportType::CalendarMultiget { props, .. } => props, CalDavReportType::SyncCollection { props, .. } => props, }; + // Reused per-event href + etag buffers for the whole REPORT page + // (benches/ROUND14.md §A6). + let mut href = String::with_capacity(base_href.len() + 48); + let mut etag = String::new(); for bundle in group_events_by_uid(events) { let anchor = match bundle.first() { Some(e) => *e, None => continue, }; - let href = format!("{}{}.ics", base_href, anchor.ical_uid); - Self::write_event_response(xml_writer, &bundle, props, &href)?; + href.clear(); + let _ = std::fmt::Write::write_fmt( + &mut href, + format_args!("{}{}.ics", base_href, anchor.ical_uid), + ); + Self::write_event_response(xml_writer, &bundle, props, &href, &mut etag)?; } Ok(()) } @@ -1232,6 +1266,7 @@ impl CalDavAdapter { bundle: &[&CalendarEventDto], props: &[QualifiedName], href: &str, + etag: &mut String, ) -> Result<()> { let anchor = bundle .first() @@ -1254,10 +1289,10 @@ impl CalDavAdapter { // If no specific props requested, return all common ones if props.is_empty() { - Self::write_event_standard_props(xml_writer, anchor, bundle)?; + Self::write_event_standard_props(xml_writer, anchor, bundle, etag)?; } else { // Write specifically requested properties - Self::write_event_requested_props(xml_writer, anchor, bundle, props)?; + Self::write_event_requested_props(xml_writer, anchor, bundle, props, etag)?; } // End prop @@ -1285,6 +1320,7 @@ impl CalDavAdapter { xml_writer: &mut Writer, anchor: &CalendarEventDto, bundle: &[&CalendarEventDto], + etag: &mut String, ) -> Result<()> { // Common WebDAV properties @@ -1293,8 +1329,13 @@ impl CalDavAdapter { // ETag anchored on the master (or first exception in // a master-less bundle — pathological state today). + // Reused buffer (benches/ROUND14.md §A6). xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?; + etag.clear(); + etag.push('"'); + etag.push_str(&anchor.id); + etag.push('"'); + xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; // Content type @@ -1304,9 +1345,9 @@ impl CalDavAdapter { )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - // Last modified + // Last modified (stack render, benches/ROUND14.md §A5) xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?; + Self::write_lastmodified_text(xml_writer, anchor.updated_at)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; // CalDAV calendar-data — the whole bundle emitted as one @@ -1329,6 +1370,7 @@ impl CalDavAdapter { anchor: &CalendarEventDto, bundle: &[&CalendarEventDto], props: &[QualifiedName], + etag: &mut String, ) -> Result<()> { for prop in props { match (prop.namespace.as_str(), prop.name.as_str()) { @@ -1338,8 +1380,12 @@ impl CalDavAdapter { } ("DAV:", "getetag") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer - .write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?; + // Reused buffer (benches/ROUND14.md §A6). + etag.clear(); + etag.push('"'); + etag.push_str(&anchor.id); + etag.push('"'); + xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } ("DAV:", "getcontenttype") => { @@ -1351,9 +1397,8 @@ impl CalDavAdapter { } ("DAV:", "getlastmodified") => { xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new( - &anchor.updated_at.to_rfc2822(), - )))?; + // Stack render (benches/ROUND14.md §A5). + Self::write_lastmodified_text(xml_writer, anchor.updated_at)?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; } diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index ee477054..6b680518 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -37,6 +37,13 @@ pub trait PasswordHasherPort: Send + Sync + 'static { pub struct TokenClaims { /// Subject identifier (user ID) pub sub: String, + /// `sub` pre-parsed to a `Uuid` at decode time so the auth middleware + /// reads it as a `Copy` on every request instead of re-parsing the + /// 36-char string per request — even on validation-cache hits, which + /// return the same `Arc` (benches/ROUND14.md §A3). Nil only + /// if a verified token somehow carried a non-UUID `sub` (unreachable for + /// tokens we sign); the middleware rejects nil defensively. + pub sub_id: Uuid, /// Expiration timestamp (seconds since Unix epoch) pub exp: i64, /// Issued at timestamp (seconds since Unix epoch) diff --git a/src/application/ports/face_ports.rs b/src/application/ports/face_ports.rs index ef016ae6..9c3461c2 100644 --- a/src/application/ports/face_ports.rs +++ b/src/application/ports/face_ports.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use uuid::Uuid; use crate::common::errors::DomainError; -use crate::domain::entities::face::{DetectedFace, Face, Person}; +use crate::domain::entities::face::{DetectedFace, Face, FaceBox, Person}; /// Detects faces in an image and produces an aligned, L2-normalized embedding /// for each. Takes raw encoded bytes (it decodes internally) so the @@ -29,7 +29,16 @@ pub trait FaceAnalyzerPort: Send + Sync + 'static { pub trait FaceRepository: Send + Sync + 'static { // ── faces ────────────────────────────────────────────────────── async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>; - async fn faces_for_file(&self, file_id: Uuid) -> Result, DomainError>; + /// Face boxes for a photo, caller-scoped — the lightbox tagging overlay + /// needs only `(id, person_id, bbox)`, so this narrow projection drops the + /// 2 KiB embedding BYTEA (+ det_score/quality/blob_hash/created_at) a full + /// `Face` fetch hydrates, and pushes the caller filter into SQL instead of + /// filtering in Rust. See benches/ROUND14.md §Q1. + async fn face_boxes_for_file( + &self, + file_id: Uuid, + user_id: Uuid, + ) -> Result, DomainError>; async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>; async fn faces_for_user(&self, user_id: Uuid) -> Result, DomainError>; /// Faces previously computed for any file sharing this content hash — diff --git a/src/application/services/people_service.rs b/src/application/services/people_service.rs index f8781422..0ff3644d 100644 --- a/src/application/services/people_service.rs +++ b/src/application/services/people_service.rs @@ -245,10 +245,11 @@ impl PeopleService { caller_id: Uuid, file_id: Uuid, ) -> Result, DomainError> { - let faces = self.repo.faces_for_file(file_id).await?; - Ok(faces + // The narrow projection scopes to the caller in SQL (WHERE user_id), + // so no post-filter is needed here. See benches/ROUND14.md §Q1. + let boxes = self.repo.face_boxes_for_file(file_id, caller_id).await?; + Ok(boxes .into_iter() - .filter(|f| f.user_id == caller_id) .map(|f| FaceBoxDto { id: f.id.to_string(), person_id: f.person_id.map(|u| u.to_string()), diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 6956e55f..c67bd694 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -146,22 +146,57 @@ pub fn build_search_results_cache( /// /// `query_lower` **must** already be lowercased by the caller so that the /// allocation happens once per search, not once per result. +/// +/// The overwhelmingly common all-ASCII filename takes an allocation-free +/// ASCII case-fold fast path — `name.to_lowercase()` (full Unicode) is pure +/// waste there, and it ran once *per result row* (and per keystroke on the +/// suggest path). Non-ASCII names fall back to the exact Unicode-lowercase +/// comparison, so behavior is unchanged (for ASCII, lowercasing preserves +/// length, so the `contains` length ratio is identical). See benches/ROUND14.md §A2. fn compute_relevance(name: &str, query_lower: &str) -> u32 { - let name_lower = name.to_lowercase(); - - if name_lower == query_lower { - 100 - } else if name_lower.starts_with(query_lower) { - 80 - } else if name_lower.contains(query_lower) { - // Bonus for shorter names (more specific match) - let ratio = query_lower.len() as f64 / name_lower.len() as f64; - 50 + (ratio * 20.0) as u32 + if name.is_ascii() { + let (nb, qb) = (name.as_bytes(), query_lower.as_bytes()); + if nb.eq_ignore_ascii_case(qb) { + 100 + } else if nb.len() >= qb.len() && nb[..qb.len()].eq_ignore_ascii_case(qb) { + 80 + } else if ascii_ci_contains(nb, qb) { + // Bonus for shorter names (more specific match). ASCII lowercase + // preserves length, so `name.len()` == the old `name_lower.len()`. + let ratio = query_lower.len() as f64 / name.len() as f64; + 50 + (ratio * 20.0) as u32 + } else { + 0 + } } else { - 0 + let name_lower = name.to_lowercase(); + if name_lower == query_lower { + 100 + } else if name_lower.starts_with(query_lower) { + 80 + } else if name_lower.contains(query_lower) { + let ratio = query_lower.len() as f64 / name_lower.len() as f64; + 50 + (ratio * 20.0) as u32 + } else { + 0 + } } } +/// ASCII case-insensitive substring test — the allocation-free equivalent of +/// `haystack_lower.contains(needle_lower)` when both are ASCII. +fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() { + return true; + } + if needle.len() > haystack.len() { + return false; + } + haystack + .windows(needle.len()) + .any(|w| w.eq_ignore_ascii_case(needle)) +} + /// Max content-index candidates fetched per search. Hydration re-filters /// them in ONE SQL round-trip, so this bounds both index and DB work. const CONTENT_HITS_LIMIT: usize = 200; diff --git a/src/domain/entities/face.rs b/src/domain/entities/face.rs index 46380833..b191bd3e 100644 --- a/src/domain/entities/face.rs +++ b/src/domain/entities/face.rs @@ -32,6 +32,19 @@ impl BoundingBox { } } +/// A face box for the lightbox tagging overlay — the narrow projection of a +/// persisted [`Face`] that the People API's `faces_for_file` needs (`id`, +/// `person_id`, `bbox`). Fetching this instead of a full [`Face`] keeps the +/// 2 KiB `embedding` BYTEA (plus det_score/quality/blob_hash/created_at) off +/// the wire on every lightbox open of a face-tagged photo. See +/// benches/ROUND14.md §Q1. +#[derive(Debug, Clone)] +pub struct FaceBox { + pub id: Uuid, + pub person_id: Option, + pub bbox: BoundingBox, +} + /// A face produced by the analyzer but not yet persisted: where it is, how /// confident the detector was, an optional quality score, and a 512-d, /// L2-normalized embedding. diff --git a/src/infrastructure/repositories/pg/face_pg_repository.rs b/src/infrastructure/repositories/pg/face_pg_repository.rs index c66073b4..a9a4e12b 100644 --- a/src/infrastructure/repositories/pg/face_pg_repository.rs +++ b/src/infrastructure/repositories/pg/face_pg_repository.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::application::ports::face_ports::FaceRepository; use crate::common::errors::DomainError; -use crate::domain::entities::face::{BoundingBox, Face, Person}; +use crate::domain::entities::face::{BoundingBox, Face, FaceBox, Person}; /// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`). type FaceRow = ( @@ -182,14 +182,31 @@ impl FaceRepository for FacePgRepository { Ok(()) } - async fn faces_for_file(&self, file_id: Uuid) -> Result, DomainError> { - let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1"); - let rows: Vec = sqlx::query_as(&sql) - .bind(file_id) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| db_err("faces_for_file", e))?; - Ok(rows.into_iter().map(row_to_face).collect()) + async fn face_boxes_for_file( + &self, + file_id: Uuid, + user_id: Uuid, + ) -> Result, DomainError> { + // Narrow projection: the lightbox needs only (id, person_id, bbox), so + // the 2 KiB embedding BYTEA + 6 unused columns stay in the DB and the + // caller filter runs in SQL (idx_faces_file drives it) rather than in + // Rust after a full-row fetch. See benches/ROUND14.md §Q1. + let rows: Vec<(Uuid, Option, Vec)> = sqlx::query_as( + "SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2", + ) + .bind(file_id) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("face_boxes_for_file", e))?; + Ok(rows + .into_iter() + .map(|(id, person_id, bbox)| FaceBox { + id, + person_id, + bbox: BoundingBox::from_slice(&bbox), + }) + .collect()) } async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> { diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index 6c937d00..be04d3b1 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -49,7 +49,14 @@ struct JwtClaims { impl From for TokenClaims { fn from(claims: JwtClaims) -> Self { + // Pre-parse the subject once at decode time (amortized over the + // validation-cache TTL) so the auth middleware reads a `Copy` instead + // of re-parsing the 36-char string per request. A verified token we + // signed always carries a UUID `sub`; nil is a safe sentinel the + // middleware rejects. See benches/ROUND14.md §A3. + let sub_id = uuid::Uuid::parse_str(&claims.sub).unwrap_or_else(|_| uuid::Uuid::nil()); TokenClaims { + sub_id, sub: claims.sub, exp: claims.exp, iat: claims.iat, diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index c78f5874..0c128f94 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -1,6 +1,6 @@ use axum::{ extract::{FromRequestParts, Request, State}, - http::{HeaderMap, StatusCode, header, request::Parts}, + http::{StatusCode, header, request::Parts}, middleware::Next, response::{IntoResponse, Response}, }; @@ -163,11 +163,17 @@ impl IntoResponse for AuthError { /// then the cookie fallback. pub async fn auth_middleware( State(state): State>, - headers: HeaderMap, mut request: Request, next: Next, ) -> Result { - let auth_header = headers + // Borrow the Authorization header straight from the request instead of + // taking axum's `HeaderMap` extractor, which clones the whole map (~2 + // allocs) on every authenticated request purely to read it + // (benches/ROUND14.md §A4). The borrow is dead by the time each arm + // reaches `request.extensions_mut()` / `next.run(request)` (NLL), so no + // owned copy is needed. + let auth_header = request + .headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()); @@ -186,9 +192,14 @@ pub async fn auth_middleware( "Token validated successfully for user: {}", claims.username ); - let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { - AuthError::InvalidToken("Invalid user ID in token".to_string()) - })?; + // Pre-parsed at decode time (benches/ROUND14.md §A3); + // nil only for a malformed sub, which we reject as before. + let user_id = claims.sub_id; + if user_id.is_nil() { + return Err(AuthError::InvalidToken( + "Invalid user ID in token".to_string(), + )); + } // A cryptographically valid token must not outlive the // account: re-check the live record so deactivation, // deletion and demotion take effect within the flags-cache @@ -296,19 +307,23 @@ pub async fn auth_middleware( use crate::interfaces::api::cookie_auth; if let Some(token_str) = - cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) + cookie_auth::extract_cookie_str(request.headers(), cookie_auth::ACCESS_COOKIE) && !token_str.is_empty() { tracing::debug!("Processing cookie-based authentication"); if let Some(auth_service) = state.auth_service.as_ref() { let token_service = &auth_service.token_service; - match token_service.validate_token(&token_str) { + match token_service.validate_token(token_str) { Ok(claims) => { tracing::debug!("Cookie token validated for user: {}", claims.username); - let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { - AuthError::InvalidToken("Invalid user ID in token".to_string()) - })?; + // Pre-parsed at decode time (benches/ROUND14.md §A3). + let user_id = claims.sub_id; + if user_id.is_nil() { + return Err(AuthError::InvalidToken( + "Invalid user ID in token".to_string(), + )); + } // Same live-account re-check as the Bearer path. On // revocation we fall through (rather than erroring) so the // browser receives the standard 401 and redirects to diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 1a50f3e7..765f7ad1 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Request, State}, - http::{HeaderMap, StatusCode, header}, + http::{StatusCode, header}, middleware::Next, response::{IntoResponse, Response}, }; @@ -70,13 +70,16 @@ impl IntoResponse for NextcloudAuthError { pub async fn basic_auth_middleware( State(state): State>, - headers: HeaderMap, mut request: Request, next: Next, ) -> Result { tracing::debug!("[NC] {} {}", request.method(), request.uri()); - let auth_header = headers + // Borrow the Authorization header directly rather than cloning the whole + // HeaderMap per NC sync request; the borrow ends at `parse_basic_auth` + // below, before any request mutation (benches/ROUND14.md §A4). + let auth_header = request + .headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .ok_or_else(|| {