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:
@@ -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(_) => {
|
||||
|
||||
@@ -16,6 +16,10 @@ use uuid::Uuid;
|
||||
* Storage usage is calculated directly from the `storage.files` table
|
||||
* by summing file sizes for each user (using the `user_id` column).
|
||||
*/
|
||||
/// Fused quota-gate row: `(user_used, user_quota, drive_used, drive_quota,
|
||||
/// drive_found)` — see [`StorageUsageService::check_upload_quotas`].
|
||||
type QuotaPairRow = (i64, i64, Option<i64>, Option<i64>, bool);
|
||||
|
||||
pub struct StorageUsageService {
|
||||
pool: Arc<PgPool>,
|
||||
user_repository: Arc<UserPgRepository>,
|
||||
@@ -372,6 +376,18 @@ impl StorageUsageService {
|
||||
// first, so this branch fires only on a deleted-drive race.
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
};
|
||||
Self::eval_drive_cap(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Drive-cap verdict over already-fetched counters. Shared by
|
||||
/// [`Self::check_drive_quota`] and the fused
|
||||
/// [`Self::check_upload_quotas`] pair so both produce byte-identical
|
||||
/// errors.
|
||||
fn eval_drive_cap(
|
||||
used: i64,
|
||||
quota: Option<i64>,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let Some(quota) = quota else {
|
||||
return Ok(()); // unlimited
|
||||
};
|
||||
@@ -391,6 +407,123 @@ impl StorageUsageService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// User-envelope verdict over already-fetched counters. Shared by
|
||||
/// `check_storage_quota` and the fused [`Self::check_upload_quotas`]
|
||||
/// pair so both produce byte-identical errors.
|
||||
fn eval_user_envelope(used: i64, quota: i64, additional_bytes: u64) -> Result<(), DomainError> {
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fused pre-upload gate: user envelope + drive cap in ONE round-trip.
|
||||
///
|
||||
/// Upload entry points used to run `check_storage_quota` then
|
||||
/// `check_drive_quota` as two serial point reads — and the NC chunked
|
||||
/// PUT pays that pair on EVERY chunk. One `LEFT JOIN` row carries both
|
||||
/// counter pairs; verdict precedence (user envelope first, then drive
|
||||
/// existence, then drive cap) and every error shape are identical to
|
||||
/// the two-call sequence (benches/ROUND12.md §6, 1.81x).
|
||||
///
|
||||
/// Row shape shared with [`Self::check_upload_quotas_by_folder`]:
|
||||
/// `(user_used, user_quota, drive_used, drive_quota, drive_found)`.
|
||||
pub async fn check_upload_quotas(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.drives d ON d.id = $2
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// [`Self::check_upload_quotas`] with the drive resolved from a parent
|
||||
/// folder id — for the REST upload paths, which hold `folder_id`.
|
||||
/// A missing folder (or a folder whose drive vanished mid-race) maps to
|
||||
/// `not_found("Folder")`, exactly like `check_drive_quota_by_folder`.
|
||||
pub async fn check_upload_quotas_by_folder(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.folders f ON f.id = $2
|
||||
LEFT JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Folder", folder_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Same as [`Self::check_drive_quota`] but resolves the drive id
|
||||
/// from a parent folder id. Mirrors
|
||||
/// [`Self::add_drive_storage_usage_delta_by_folder`] so the upload
|
||||
@@ -563,36 +696,7 @@ impl StorageUsagePort for StorageUsageService {
|
||||
// Narrow 2-column read — the full user row carries the up-to-512 KiB
|
||||
// avatar `image` column, paid on every upload quota check otherwise.
|
||||
let (used, quota) = self.user_repository.get_storage_usage(user_id).await?;
|
||||
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Self::eval_user_envelope(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
async fn get_user_storage_info(&self, user_id: Uuid) -> Result<(i64, i64), DomainError> {
|
||||
|
||||
Reference in New Issue
Block a user