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
This commit is contained in:
+18
@@ -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
|
||||
|
||||
@@ -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<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).
|
||||
@@ -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(),
|
||||
|
||||
@@ -707,15 +707,22 @@ async fn section_save_faces(pool: &Arc<PgPool>, 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<f32>, Vec<u8>, Option<f32>, Option<String>) =
|
||||
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)",
|
||||
|
||||
@@ -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<T: std::str::FromStr>(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<F: FnMut()>(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::<Utc>::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<DateTime<Utc>> = secs.iter().map(|&s| DateTime::<Utc>::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)");
|
||||
}
|
||||
@@ -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<f32>` 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<T: std::str::FromStr>(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, f64) {
|
||||
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = s.len();
|
||||
(
|
||||
s.iter().sum::<f64>() / 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<f32>` decode the BEFORE path pays for a column it never reads.
|
||||
fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
|
||||
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<Uuid>, Vec<f32>)> {
|
||||
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<u8> = r.get("embedding");
|
||||
let _embedding = bytes_to_embedding(&emb_bytes);
|
||||
if user_id != caller {
|
||||
return None;
|
||||
}
|
||||
let bbox: Vec<f32> = 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<Uuid>, Vec<f32>)> {
|
||||
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<f32> = 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<Uuid>.
|
||||
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)");
|
||||
}
|
||||
@@ -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<string> {
|
||||
let acc: string[] = [];
|
||||
let set = new Set<string>();
|
||||
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<string> {
|
||||
const set = new Set<string>();
|
||||
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<string>): 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<string, unknown> = 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<string, unknown> = {}): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['n'];
|
||||
}
|
||||
function tAfter(paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['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<string, unknown>) => 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);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,12 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
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<string, unknown> = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Translate a key.
|
||||
* - `t(key)` / `t(key, params)` — interpolation params object.
|
||||
@@ -209,11 +215,11 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
*/
|
||||
export function t(
|
||||
key: string,
|
||||
paramsOrFallback: string | Record<string, unknown> = {},
|
||||
paramsOrFallback: string | Record<string, unknown> = 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];
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
ts: DateTime<Utc>,
|
||||
) -> 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<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
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<W>,
|
||||
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")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TokenClaims>` (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)
|
||||
|
||||
@@ -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<Vec<Face>, 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<Vec<FaceBox>, DomainError>;
|
||||
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
|
||||
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
|
||||
/// Faces previously computed for any file sharing this content hash —
|
||||
|
||||
@@ -245,10 +245,11 @@ impl PeopleService {
|
||||
caller_id: Uuid,
|
||||
file_id: Uuid,
|
||||
) -> Result<Vec<FaceBoxDto>, 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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Uuid>,
|
||||
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.
|
||||
|
||||
@@ -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<Vec<Face>, DomainError> {
|
||||
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
|
||||
let rows: Vec<FaceRow> = 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<Vec<FaceBox>, 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<Uuid>, Vec<f32>)> = 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> {
|
||||
|
||||
@@ -49,7 +49,14 @@ struct JwtClaims {
|
||||
|
||||
impl From<JwtClaims> 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,
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AuthError> {
|
||||
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
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, NextcloudAuthError> {
|
||||
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(|| {
|
||||
|
||||
Reference in New Issue
Block a user