Benchmark-gated round (benches/ROUND12.md; every change ships with a BEFORE/AFTER harness + equivalence gates, one candidate rejected by its own bench): DB / query shapes (bench_round12_queries): - NC sharee search: username-only projection instead of the 21-column row (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index). - Password login: delete the redundant full-row update_user — create_session already stamps last_login_at in its own txn (4.45x per login). - Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login now compares profile state in memory and issues ZERO queries when nothing changed (was: full 17-column rewrite per login). - Refresh rotation: revoke+insert+stamp fused into one transaction via new rotate_session port method (1.18x). - WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file + check(Update) overlapped with tokio::join!, original result precedence (cold 1.34x). - Upload quota gate: user-envelope + drive-cap checks fused into ONE round-trip (check_upload_quotas) — the NC chunked PUT pays this per chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep error shapes byte-identical. CPU / allocs (bench_round12_micro): - sized_json: pre-sized listing serialization replacing axum Json's 128 B seed + doubling-realloc chain on files/folder-resources/photos/search responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical). - Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated byte-identical incl. 304s). - Media capture-metadata: single-read extraction — nom-exif now parses the buffer kamadak already read (zero-copy Bytes) and videos open once with a kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x cold cache; extraction outputs gated identical incl. the MIME-mislabel track fallback). - Chunked-upload session ops: owner gate folded into the operation's own DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs, 1.28x per chunk). Blob cache (bench_blob_cache_index + round-3 regression guard): - CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache with byte weigher. The mutex serialized every cached chunk read and scaled NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are lock-free (2.17x at K=2). Byte budget now enforced by moka (manual current_size + collect_evictions machinery deleted); eviction listener unlinks size-evicted files only (Replaced entries keep their file — gated). Single-flight miss gate unchanged (16 concurrent misses -> 1 fetch re-verified via the round-3 harness). - put_blob now populates the cache BEFORE the inner backend consumes the source file (the old order failed 100% of the time — local renames, S3/Azure delete the source — so the first read after a whole-file put re-downloaded from the remote); inner-put failure invalidates the entry. Frontend (vitest gates): - List-view thumbnails request the 150px icon rendition instead of 400px preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per thumbnail across list views); grid keeps preview. Rejected by its own bench (kept as evidence in bench_round12_micro §2): - Single-pass compression predicate: the monomorphized And-chain already costs ~4.6 ns / 0 allocs total; the fused node measured within noise. New migration: 20260719000000_users_search_trgm.sql (trgm indexes). Deferred with prepared design: grouped file/grid view virtualization (single-VirtualRows flatten, the photos pattern) — next round's headline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
17 KiB
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 persistlast_login_at… whichcreate_sessionoverwrites in its own transaction three lines later. Nothing reads the row in between (verified). The call is deleted; the in-memoryregister_login()stays so the response DTO carries the timestamp. - Magic-link redemption kept its
update_userfor the email-verified stamp only (last-login again covered bycreate_session) — now a narrowWHERE … AND email_verified_at IS NULLsingle-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_subjectalready 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 guardedIS DISTINCT FROMno-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_profileruns the guarded narrow UPDATE (image + conditional stamp,update_storage_usagepattern) 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'sAnd-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
ifblocks 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 existingVirtualRows(photos-timeline pattern — headers as first-class rows, grid rows as fixed-height strips ofgridColumns(width)tiles), which also collapses the per-sectionVirtualListscroll listeners the grouped LIST path pays today (onegetBoundingClientRectper section per scroll tick). This is the next round's headline; it wants its own pass with UI gates. - Duplicate
TraceLayeron/api(routes.rslayers it again under the globalClientIpMakeSpanlayer) and the per-requestclient_ipString in the span factory — small, want their own measured arms. CachedBlobBackend::local_blob_pathsyncstat(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_pathfull-file frame scan runs even when the ID3TLENtag 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
orientationis 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.