perf: round 12 — auth write-path narrowing, fused quota gate, moka blob-cache index, media single-read, sized listing JSON

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
This commit is contained in:
Claude
2026-07-19 01:32:00 +00:00
parent a793cd62eb
commit 50eca0627f
33 changed files with 3989 additions and 410 deletions
@@ -786,9 +786,14 @@ impl AuthApplicationService {
lc.dispatch_login(&user).await;
}
// Update last login
// Update last login (in-memory only — the DTO below carries it).
// The full-row `update_user` this path used to issue was 100%
// redundant: `create_session` stamps `last_login_at`/`updated_at`
// in its own transaction right below, and nothing re-reads the row
// in between. Dropping it removes one transaction + a 17-column
// rewrite (incl. the up-to-512 KiB avatar) per password login
// (benches/ROUND12.md §2, 4.45x).
user.register_login();
self.user_storage.update_user(user.clone()).await?;
// Generate tokens using the injected token service
let access_token = self.token_service.generate_access_token(&user)?;
@@ -1017,9 +1022,12 @@ impl AuthApplicationService {
// PR 23: clicking the magic-link IS proof of email control —
// stamp the verification (idempotent, preserves the first
// timestamp). Applies to both invitation and login-via-email
// tokens.
// tokens. Narrow single-column write: `last_login_at` is stamped
// by `create_session` below, so the full-row `update_user` this
// path used to issue only ever contributed the verification
// timestamp (benches/ROUND12.md §3, 8.9x).
user.mark_email_verified();
self.user_storage.update_user(user.clone()).await?;
self.user_storage.mark_email_verified(user.id()).await?;
let access_token = self.token_service.generate_access_token(&user)?;
let refresh_token = self.token_service.generate_refresh_token();
@@ -1163,15 +1171,15 @@ impl AuthApplicationService {
));
}
// Revoke current session before issuing the next token in the family
self.session_storage.revoke_session(session.id()).await?;
// Generate new tokens
let access_token = self.token_service.generate_access_token(&user)?;
let new_refresh_token = self.token_service.generate_refresh_token();
// New session inherits the family_id so reuse of any ancestor triggers
// full-family revocation
// full-family revocation. Revoking the old session and inserting the
// new one happen in ONE transaction (`rotate_session`) — this path
// used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients
// rotate constantly (benches/ROUND12.md §4).
let new_session = Session::new(
user.id(),
new_refresh_token.clone(),
@@ -1181,7 +1189,9 @@ impl AuthApplicationService {
session.family_id(),
);
self.session_storage.create_session(new_session).await?;
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
Ok(AuthResponseDto {
user: UserDto::from(user),
@@ -2030,6 +2040,24 @@ impl AuthApplicationService {
Ok(users.into_iter().map(UserDto::from).collect())
}
/// Username-only search for the NC sharee autocomplete: identical
/// predicate / order / limit to [`search_users`], but the repository
/// projects just `username` — no 21-column hydration (incl. the
/// up-to-512 KiB avatar `image`) per matched row, per keystroke
/// (benches/ROUND12.md §1). NULL usernames (email-only signups) are
/// filtered app-side, exactly like the wide flow's post-limit filter.
pub async fn search_sharee_usernames(
&self,
query: &str,
limit: i64,
) -> Result<Vec<String>, DomainError> {
let names = self
.user_storage
.search_usernames(query, limit, false)
.await?;
Ok(names.into_iter().flatten().collect())
}
// ========================================================================
// Admin User Management Methods
// ========================================================================
@@ -2607,6 +2635,15 @@ impl AuthApplicationService {
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_login(&existing_user).await;
}
// Decide BEFORE mutating: the row just fetched already
// carries the stored avatar + verification stamp, so the
// repeat-login common case (same IdP picture, already
// verified) skips the DB entirely — the old shape rewrote
// all 17 columns per login, and even a guarded UPDATE
// would ship the avatar over the wire just to compare it
// (benches/ROUND12.md §3b).
let needs_profile_sync = existing_user.email_verified_at().is_none()
|| existing_user.image() != claims.picture.as_deref();
existing_user.register_login();
existing_user.set_image(claims.picture.clone());
// PR 23: retroactive email verification for OIDC users
@@ -2615,7 +2652,16 @@ impl AuthApplicationService {
// any user reaching this branch has a verified email
// by the IdP's word; stamping is safe and idempotent.
existing_user.mark_email_verified();
self.user_storage.update_user(existing_user.clone()).await?;
// Narrow guarded sync instead of the 17-column row rewrite:
// persists the IdP avatar + the verification stamp only
// when either actually changed; `last_login_at` is stamped
// by `create_session` at the end of this flow
// (benches/ROUND12.md §3).
if needs_profile_sync {
self.user_storage
.sync_oidc_login_profile(existing_user.id(), claims.picture.as_deref())
.await?;
}
existing_user
}
Err(_) => {