292 lines
17 KiB
Markdown
292 lines
17 KiB
Markdown
|
|
# Round 12 — auth write-path narrowing, fused quota gate, moka blob-cache index, media single-read, sized listing JSON
|
|||
|
|
|
|||
|
|
Benchmark-gated, same rule as ROUND2-11: every change ships with a
|
|||
|
|
BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't
|
|||
|
|
beat its BEFORE gets rolled back or redesigned. One candidate went through
|
|||
|
|
exactly that loop this round (§Rejected): the single-pass compression
|
|||
|
|
predicate — the profiler-plausible "28 redundant Content-Type reads" turned
|
|||
|
|
out to cost ~4.6 ns TOTAL once monomorphized, and the fused replacement
|
|||
|
|
measured within noise, so the declarative chain stays.
|
|||
|
|
|
|||
|
|
Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release
|
|||
|
|
profile; frontend on Node 22 / vitest 4. Reproduce any row with the command
|
|||
|
|
in its section.
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
| # | change | key metric | before → after |
|
|||
|
|
|--:|---|---|---|
|
|||
|
|
| Q1 | NC sharee search: username-only projection (was 21 wide columns incl. the ≤512 KiB avatar per match) | 26-row page, 3 000 users, all matches avatared | 11.77 → 2.37 ms (**4.98x**) |
|
|||
|
|
| Q1b | + `gin_trgm_ops` indexes on `auth.users` (migration 20260719000000) | same page, leading-wildcard ILIKE | → 0.215 ms (**54.7x** total) |
|
|||
|
|
| Q2 | Password login: redundant full-row `update_user` deleted (`create_session` already stamps `last_login_at`) | ms/login, 256 KiB avatar | 2.96 → 0.67 (**4.45x**) · −1 txn, −17-column rewrite, −512 KiB clone |
|
|||
|
|
| Q3 | Email-verified stamp → narrow conditional UPDATE (magic-link) | ms/stamp | 2.20 → 0.25 (**8.9x**) |
|
|||
|
|
| Q3b | OIDC repeat login → in-memory compare, sync only on change | queries per repeat login | full-row rewrite (2.37 ms) → **0 queries** |
|
|||
|
|
| Q4 | Refresh-token rotation: 2 transactions → 1 (`rotate_session`) | ms/rotation | 1.135 → 0.959 (**1.18x**) |
|
|||
|
|
| Q5 | WOPI CheckFileInfo triple → `tokio::join!` (real `PgAclEngine`) | ms/call | cold 0.485 → 0.363 (**1.34x**) · warm 0.228 → 0.209 |
|
|||
|
|
| Q6 | Upload quota pair → ONE fused read (user envelope + drive cap) — NC chunk PUT pays it per chunk | ms/check | 0.350 → 0.193 (**1.81x**) · 2 → 1 queries/chunk |
|
|||
|
|
| M1 | Listing JSON: pre-sized buffer (`sized_json`) vs axum `Json`'s 128 B seed | 500-row page | 282.4 → 201.0 µs (**1.40x**) · 13 → 2 allocs |
|
|||
|
|
| M3 | Security headers: 4 `SetResponseHeaderLayer` + CSP middleware → 1 fused pass | per request (incl. router) | 5.35 → 3.74 µs (**1.43x**) · −26 allocs |
|
|||
|
|
| M4 | Media capture-metadata: single-read (images were read 2-3×, videos opened 2×) | warm geomean / cold cache | **1.44x** warm · **1.6-3.2x** cold · opens 2-3 → 1 |
|
|||
|
|
| M5 | Chunked-upload session ops: 5 → 3 map lookups + stack-encoded uuid compare | ns per chunk (prepare+commit) | 469 → 366 (**1.28x**) · −2 allocs |
|
|||
|
|
| B1 | Blob-cache index: `Mutex<LruCache>` → moka byte-weigher | pure index probes, K readers | K=2 **2.17x**, K=4 1.61x, K=8 1.46x (mutex scaled NEGATIVELY: 2.08 → 1.07 Mops/s from 1 → 2 readers) |
|
|||
|
|
| B2 | `put_blob` populates the cache BEFORE the inner backend consumes the source (was: after → failed 100%) | first read after whole-file put | full remote re-download → local hit |
|
|||
|
|
| F1 | SPA list view: 150 px `icon` thumbnails (was 400 px `preview` into a 40 px slot) | pixels per list thumbnail | **~7.1x fewer** (≈4-5x fewer bytes) |
|
|||
|
|
|
|||
|
|
## [Q1] NC sharee search — the 512 KiB-per-row autocomplete
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cargo run --release --features bench --example bench_round12_queries # §1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`handle_sharees_search` fired `search_users` per keystroke — the full
|
|||
|
|
21-column row (incl. the ≤512 KiB avatar data-URI `image`, TOAST-detoasted
|
|||
|
|
per match) hydrated into `User` → `UserDto`, of which the handler read ONLY
|
|||
|
|
`username`. And the leading-wildcard `ILIKE '%q%'` had no trigram index, so
|
|||
|
|
every keystroke seq-scanned `auth.users` (contacts/files/folders all have
|
|||
|
|
`gin_trgm_ops`; users was the gap). Now: `search_usernames` port method
|
|||
|
|
(same WHERE/ORDER/LIMIT, username-only projection; NULL usernames filtered
|
|||
|
|
app-side exactly like the wide flow's post-limit filter) + the two trgm
|
|||
|
|
indexes. Gates: identical username lists, with and without the indexes.
|
|||
|
|
The wide method stays for the admin table (which serializes `image`).
|
|||
|
|
|
|||
|
|
## [Q2][Q3][Q3b] Auth write-path narrowing
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cargo run --release --features bench --example bench_round12_queries # §2-3
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **Login** ran `update_user(user.clone())` — a transaction rewriting all
|
|||
|
|
17 columns (incl. the avatar, plus a 512 KiB deep clone to feed it) —
|
|||
|
|
purely to persist `last_login_at`… which `create_session` overwrites in
|
|||
|
|
its own transaction three lines later. Nothing reads the row in between
|
|||
|
|
(verified). The call is deleted; the in-memory `register_login()` stays
|
|||
|
|
so the response DTO carries the timestamp.
|
|||
|
|
- **Magic-link redemption** kept its `update_user` for the email-verified
|
|||
|
|
stamp only (last-login again covered by `create_session`) — now a narrow
|
|||
|
|
`WHERE … AND email_verified_at IS NULL` single-column UPDATE, idempotency
|
|||
|
|
moved into SQL (gated: second stamp is a 0-row no-op, first timestamp
|
|||
|
|
preserved).
|
|||
|
|
- **OIDC repeat login** additionally syncs the IdP avatar. The row fetched
|
|||
|
|
by `get_user_by_oidc_subject` already carries the stored avatar +
|
|||
|
|
verification stamp, so the service now compares IN MEMORY and issues NO
|
|||
|
|
query at all on the repeat-login common case (same picture, already
|
|||
|
|
verified) — the bench's §3b arm is the reason: even a guarded
|
|||
|
|
`IS DISTINCT FROM` no-op UPDATE ships the ≤512 KiB avatar parameter over
|
|||
|
|
the wire just to compare it (1.20 ms vs the 2.37 ms full-row rewrite;
|
|||
|
|
the in-memory skip makes it 0). When something DID change,
|
|||
|
|
`sync_oidc_login_profile` runs the guarded narrow UPDATE (image +
|
|||
|
|
conditional stamp, `update_storage_usage` pattern) instead of the
|
|||
|
|
17-column rewrite.
|
|||
|
|
|
|||
|
|
## [Q4] Refresh rotation — one transaction
|
|||
|
|
|
|||
|
|
`refresh_token` paid two full BEGIN/COMMIT pairs per rotation
|
|||
|
|
(`revoke_session` then `create_session`), and DAV clients rotate
|
|||
|
|
constantly. New `rotate_session(old_id, new_session)` port method: revoke +
|
|||
|
|
insert + last-login stamp in one `with_transaction`. Gates: old session
|
|||
|
|
revoked, new session live, reuse-detection semantics untouched (family
|
|||
|
|
revocation still fires on replay). The per-rotation "Session … revoked"
|
|||
|
|
info-line is gone with the old method call (routine rotation is not a
|
|||
|
|
security event; explicit logout/family revocation still log).
|
|||
|
|
|
|||
|
|
## [Q5] WOPI CheckFileInfo — three independent lookups overlapped
|
|||
|
|
|
|||
|
|
The handler ran require(Read) → get_file → check(Update) serially; all
|
|||
|
|
three key off `(caller, file)` alone. Now `tokio::join!` with results
|
|||
|
|
evaluated in the original precedence (Read gate first, then 404, then the
|
|||
|
|
can_write hint — deny responses byte-identical; the Update probe still
|
|||
|
|
skips its query when the token has no write claim). Same fusion applied to
|
|||
|
|
`authorize_wopi_access` (host page / editor-url). Cold is the shape that
|
|||
|
|
matters: office editors poll CheckFileInfo through a session, but each
|
|||
|
|
(file × TTL-window) pays the cold chain once.
|
|||
|
|
|
|||
|
|
## [Q6] Fused upload-quota gate
|
|||
|
|
|
|||
|
|
`refuse_if_over_quota` (NC chunked PUT — runs on EVERY chunk) issued the
|
|||
|
|
user-envelope read and the drive-cap read serially. One `LEFT JOIN` row
|
|||
|
|
now carries both counter pairs; the verdict evaluators were extracted
|
|||
|
|
(`eval_user_envelope` / `eval_drive_cap`) and are shared by the old point
|
|||
|
|
methods and the fused one, so every error string is identical by
|
|||
|
|
construction. Gates: verdict identity across ok / drive-over / user-over
|
|||
|
|
(precedence) / unlimited / missing-drive. A `check_upload_quotas_by_folder`
|
|||
|
|
twin exists for folder-keyed callers; the three REST once-per-upload pair
|
|||
|
|
sites were left as-is (their two checks carry different rejection logs, and
|
|||
|
|
one query per whole upload isn't worth entangling that — see §Skipped).
|
|||
|
|
|
|||
|
|
## [M1] `sized_json` — the 128-byte seed on every listing
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cargo run --release --features bench --example bench_round12_micro # §1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
axum's `Json` serializes into `BytesMut::with_capacity(128)`; a 500-row
|
|||
|
|
listing (~190 KB) grows it through ~11 doubling reallocs, memcpy-ing ~1.3×
|
|||
|
|
the payload. `interfaces::api::sized_json` pre-sizes from the row count
|
|||
|
|
(FileDto ≈ 380 B serialized; estimate 384) and serves byte-identical output
|
|||
|
|
(gated). Applied to the four hot listing responses: `list_files` (which is
|
|||
|
|
UNBOUNDED — no page cap), folder resources, photos timeline, search (both
|
|||
|
|
verbs).
|
|||
|
|
|
|||
|
|
## [M3] Security-header stack 5 → 1
|
|||
|
|
|
|||
|
|
The CSP middleware already post-processed every response; the four static
|
|||
|
|
headers (`x-content-type-options`, `x-frame-options`, `referrer-policy`,
|
|||
|
|
`permissions-policy`) each rode their own `SetResponseHeaderLayer` on top.
|
|||
|
|
Folded into the same pass — inserted before the 304 early-return because
|
|||
|
|
the standalone layers stamped 304s too. Gate: status + full sorted header
|
|||
|
|
set byte-identical for json / html / 304 through real axum routers.
|
|||
|
|
|
|||
|
|
## [M4] Media capture-metadata single-read (the ROUND11 deferred lead)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cargo run --release --features bench --example bench_round12_micro # §4
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`extract_blocking` read each image once wholesale for kamadak, then
|
|||
|
|
nom-exif re-opened the SAME file (`read_exif(path)`), and date-less images
|
|||
|
|
paid a third open (`read_track(path)` fallback). Videos opened twice (a
|
|||
|
|
doomed `read_exif` sniff, then `read_track`). Now: nom-exif parses from the
|
|||
|
|
kamadak buffer zero-copy (`MediaSource::from_memory` over the same `Bytes`
|
|||
|
|
allocation, API verified on the pinned 3.6.1), one reused `MediaParser`,
|
|||
|
|
and videos open once with a `kind()` dispatch. The track fallback for
|
|||
|
|
images SURVIVES (fed from the same bytes) — it covers MIME-mislabeled rows,
|
|||
|
|
the only case where it ever produced a date; behaviour is
|
|||
|
|
observable-identical (gated over dated/undated JPEG, PNG, crafted MP4 —
|
|||
|
|
corpus asserted non-vacuous: the crafted EXIF date and mvhd creation time
|
|||
|
|
must actually extract). Warm: 1.44x geomean. Cold cache (`drop_caches`
|
|||
|
|
arms): dated JPEG 0.81 → 0.34 ms, undated 0.97 → 0.30, PNG 0.12 → 0.06,
|
|||
|
|
MP4 0.050 → 0.032. Per-image opens 2-3 → 1; the backfill sweeps multiply
|
|||
|
|
this by the library size.
|
|||
|
|
|
|||
|
|
## [M5] Chunked-upload session ops
|
|||
|
|
|
|||
|
|
`prepare_chunk` ran `verify_session_owner` (own DashMap lookup + a
|
|||
|
|
`Uuid::to_string`) then re-fetched the same entry; `commit_chunk` did the
|
|||
|
|
same plus its `get_mut` (3 lookups + allocation per chunk). The owner gate
|
|||
|
|
now rides the operation's own lookup (same anti-enum not-found for unknown
|
|||
|
|
and foreign sessions — gated), and the uuid compares against a
|
|||
|
|
stack-encoded hyphenated form. 5 → 3 shard-lock round-trips and −2 allocs
|
|||
|
|
per chunk cycle.
|
|||
|
|
|
|||
|
|
## [B1][B2] Blob-cache: moka byte-weigher index + the put_blob ordering fix
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cargo run --release --features bench --example bench_blob_cache_index
|
|||
|
|
cargo run --release --features bench --example bench_blob_cache # regression guard
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The ROUND11 deferred headline. The cache index was a
|
|||
|
|
`tokio::sync::Mutex<LruCache>`: every cached chunk read took the one global
|
|||
|
|
async mutex to probe+promote (LRU `get` needs `&mut`), so a 100-chunk video
|
|||
|
|
playback was 100 serialized critical sections and concurrent readers
|
|||
|
|
contended process-wide — measured NEGATIVE scaling (2.08 → 1.07 Mops/s
|
|||
|
|
going from 1 to 2 readers). `moka::sync::Cache` with a byte weigher makes
|
|||
|
|
the probe lock-free (K=2 **2.17x**, K=8 1.46x; end-to-end warm reads with
|
|||
|
|
real files 1.00-1.15x on this 4-core box — the gap is the index share of
|
|||
|
|
the path and widens with cores/readers). moka also absorbs the byte budget:
|
|||
|
|
the manual `current_size` counter + `collect_evictions` sweep are gone; an
|
|||
|
|
eviction listener unlinks size-evicted `.blob` files. Safety gates: budget
|
|||
|
|
enforced (100 × 1 MiB into a 10 MiB cap → ≥88 files unlinked, survivors
|
|||
|
|
readable), a Replaced entry does NOT unlink its file, Explicit
|
|||
|
|
invalidations unlink at their call sites, and the per-hash single-flight
|
|||
|
|
still collapses 16 concurrent misses to 1 fetch. The `CachedRef` clone
|
|||
|
|
bundle (incl. a `cache_dir` PathBuf clone paid on every HIT for a miss-only
|
|||
|
|
struct) is gone — internals now borrow `self`.
|
|||
|
|
|
|||
|
|
Two behavioural notes, both strict improvements: the write-through PUT
|
|||
|
|
paths now respect the byte budget (the old index deliberately skipped
|
|||
|
|
eviction there, letting write bursts overshoot until the next read-miss);
|
|||
|
|
and a restored over-budget cache trims at startup instead of on the next
|
|||
|
|
insert.
|
|||
|
|
|
|||
|
|
**B2 (the ROUND11 correctness note):** `put_blob` populated the cache AFTER
|
|||
|
|
`inner.put_blob` — but every inner backend consumes the source file (local
|
|||
|
|
renames it, S3/Azure delete it post-upload), so the `fs::copy` failed 100%
|
|||
|
|
of the time, silently (`let _`), and the first read after a whole-file put
|
|||
|
|
(the backend-migration copier) re-downloaded the blob from the remote.
|
|||
|
|
Cache-first now, with invalidate+unlink if the inner put fails so a
|
|||
|
|
rejected blob can never be served. The round-3 stampede guard re-run passes
|
|||
|
|
against the migrated backend (16 → 1 remote fetches, cache file verified).
|
|||
|
|
|
|||
|
|
## [F1] SPA list-view thumbnails (vitest gate)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cd frontend && npx vitest run src/lib/api/endpoints/round12.bench.test.ts
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Both views requested the 400 px `preview` rendition; the list row draws it
|
|||
|
|
in a 40×40 slot (the 150 px `icon` rendition is already ≥2× retina density
|
|||
|
|
there). `thumbSizeForView` switches list rows to `icon` — ~7.1x fewer
|
|||
|
|
pixels per thumbnail, roughly 4-8 KB vs 20-40 KB encoded WebP each, across
|
|||
|
|
files/recent/favorites/trash/shared list views. Grid keeps `preview`
|
|||
|
|
(100×70 slot at 2x DPR genuinely needs it).
|
|||
|
|
|
|||
|
|
## Rejected / reworked this round (the discipline working)
|
|||
|
|
|
|||
|
|
- **Single-pass compression predicate**: the sweep flagged "~28 redundant
|
|||
|
|
Content-Type header reads per compressible response" in `main.rs`'s
|
|||
|
|
`And`-chain. The bench says otherwise: the monomorphized chain runs in
|
|||
|
|
**4.6 ns / 0 allocs** total (straight-line inlined probes), and the
|
|||
|
|
hand-fused single-pass node measured 5.2 ns on the compressible hot case
|
|||
|
|
— within noise, sometimes slower. Not shipped; the declarative chain
|
|||
|
|
stays. `bench_round12_micro` §2 keeps the reproducible evidence.
|
|||
|
|
|
|||
|
|
## Considered and skipped (cost/benefit, not measurement)
|
|||
|
|
|
|||
|
|
- **REST per-upload quota pair fusion** (multipart / native-chunked /
|
|||
|
|
delta): the two checks sit in separate `if` blocks with distinct
|
|||
|
|
rejection logs and folder-id guards; fusing saves ONE query per whole
|
|||
|
|
upload (not per chunk) and would entangle that flow. The NC per-chunk
|
|||
|
|
site — the hot one — is fused (Q6).
|
|||
|
|
- **NC per-session quota budget cache** (0 queries per chunk instead of 1):
|
|||
|
|
needs a staleness/invalidation story vs concurrent sessions; the fused
|
|||
|
|
read already halves the per-chunk cost with bit-identical semantics.
|
|||
|
|
Flagged for a future round.
|
|||
|
|
- **`lto = "fat"` on the release profile**: the bench profile already uses
|
|||
|
|
it; flipping release trades a large link-time regression for every
|
|||
|
|
contributor and CI/Docker build against a low-single-digit runtime gain.
|
|||
|
|
That's a project-level call for maintainers, not a bench-gated code
|
|||
|
|
change — flagged, not shipped.
|
|||
|
|
|
|||
|
|
## Deferred / flagged (not shipped this round)
|
|||
|
|
|
|||
|
|
- **Grouped file/grid views are still unvirtualized** (files route
|
|||
|
|
`groupBy != ''` mounts EVERY row in both view modes; ResourceList's
|
|||
|
|
grouped GRID branch too — trash is grouped-by-default). Design prepared
|
|||
|
|
this round: flatten groups into the existing `VirtualRows`
|
|||
|
|
(photos-timeline pattern — headers as first-class rows, grid rows as
|
|||
|
|
fixed-height strips of `gridColumns(width)` tiles), which also collapses
|
|||
|
|
the per-section `VirtualList` scroll listeners the grouped LIST path
|
|||
|
|
pays today (one `getBoundingClientRect` per section per scroll tick).
|
|||
|
|
This is the next round's headline; it wants its own pass with UI gates.
|
|||
|
|
- **Duplicate `TraceLayer` on `/api`** (`routes.rs` layers it again under
|
|||
|
|
the global `ClientIpMakeSpan` layer) and the **per-request `client_ip`
|
|||
|
|
String** in the span factory — small, want their own measured arms.
|
|||
|
|
- **`CachedBlobBackend::local_blob_path` sync `stat`** (ROUND10/11 flag
|
|||
|
|
stands — background-extraction paths only; needs an async port variant).
|
|||
|
|
- **Media hooks read the same blob up to 3×** per upload (thumbnail +
|
|||
|
|
capture-metadata + faces each pull it independently; the latter two read
|
|||
|
|
the RAW blob path directly, bypassing the content cache — and, on
|
|||
|
|
encrypted deployments, reading ciphertext: correctness note for
|
|||
|
|
maintainers, same class as the ROUND11 put_blob note).
|
|||
|
|
- **`mp3_duration::from_path` full-file frame scan** runs even when the
|
|||
|
|
ID3 `TLEN` tag is present (ingest-path only). Preferring TLEN is a
|
|||
|
|
speed/accuracy tradeoff on VBR files — maintainer call.
|
|||
|
|
- **Thumbnail orientation re-parses EXIF** that capture-metadata also
|
|||
|
|
parses; reusing the persisted `orientation` is ordering-dependent
|
|||
|
|
(hooks run concurrently) — needs a small sequencing decision.
|
|||
|
|
|
|||
|
|
## Environment / methodology
|
|||
|
|
|
|||
|
|
- `cargo run --release --features bench --example bench_round12_queries`
|
|||
|
|
— needs Postgres; seeds and sweeps its own fixtures (BENCH_PASSES,
|
|||
|
|
BENCH_SHR_USERS, BENCH_WOPI_FILES, BENCH_WARM_ITERS).
|
|||
|
|
- `cargo run --release --features bench --example bench_round12_micro`
|
|||
|
|
— counting allocator; §4's cold arms drop the page cache (root; set
|
|||
|
|
BENCH_COLD_ITERS=0 to skip).
|
|||
|
|
- `cargo run --release --features bench --example bench_blob_cache_index`
|
|||
|
|
— index scaling + eviction/single-flight safety gates.
|
|||
|
|
- `cargo run --release --features bench --example bench_blob_cache`
|
|||
|
|
— round-3 cross-round regression guard (passes against the moka index).
|
|||
|
|
- `cd frontend && npx vitest run src/lib/api/endpoints/round12.bench.test.ts`.
|