perf(quota): stop recomputing storage usage on every GET /api/auth/me

GET /api/auth/me ran a synchronous O(N) SUM(size) over all the user's
files plus an unconditional UPDATE of auth.users on every call — one of
the most frequently hit endpoints — adding per-request latency, DB write
load, dead tuples and WAL even when nothing changed.

- /api/auth/me now serves the cached storage_used_bytes column instead of
  recomputing it inline.
- New StorageUsageService::start_reconciliation_job runs a periodic sweep
  on the maintenance pool that keeps the cached value current for every
  mutation (uploads, deletes, trash), so freshness no longer depends on
  hitting /me. Interval via OXICLOUD_STORAGE_USAGE_RECONCILE_SECS (default
  600s, floored at 30s; first sweep deferred one interval to avoid boot load).
- update_storage_usage only writes when the value actually changes
  (IS DISTINCT FROM), so the sweep produces no dead tuple / WAL on no-ops.
- New covering partial index idx_files_user_size_active makes the usage
  SUM an index-only scan instead of a heap scan over all the user's files.

Also collapse the same pre-existing clippy collapsible_else_if in
carddav_handler that blocks the -D warnings gate on this base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-07 01:13:58 +02:00
parent c5e0800336
commit d7c6894c80
8 changed files with 98 additions and 33 deletions
+7
View File
@@ -34,6 +34,13 @@ OXICLOUD_SERVER_HOST=127.0.0.1
# Maximum upload size in bytes (default: 10 GB on 64-bit)
#OXICLOUD_MAX_UPLOAD_SIZE=10737418240
# How often (seconds) the background sweep reconciles each user's cached
# storage usage with the real sum of their files (default: 600 = 10 min).
# GET /api/auth/me serves the cached value instead of recomputing per request;
# this sweep keeps it fresh for deletes/trash too. Lower = fresher quota,
# higher = less background DB work. Minimum enforced: 30s.
#OXICLOUD_STORAGE_USAGE_RECONCILE_SECS=600
# Allow multiple processes to bind to the same port (SO_REUSEPORT).
# DISABLED by default — leaving this off means a second accidental instance
# will fail immediately with "address already in use", which is the safe behaviour.
@@ -0,0 +1,18 @@
-- Covering partial index for per-user storage-usage accounting.
--
-- The usage calculation is:
-- SELECT COALESCE(SUM(size), 0) FROM storage.files
-- WHERE user_id = $1 AND NOT is_trashed;
--
-- Without an index that carries `size`, this is a heap scan over every file
-- the user owns. This index lets PostgreSQL satisfy it with an index-only scan:
-- * keyed by user_id → only the target user's rows are visited
-- * INCLUDE (size) → the sum is read straight from the index
-- * WHERE NOT is_trashed → matches the query predicate exactly and keeps
-- the index small (trashed files are excluded)
--
-- Used by the per-upload usage update and the periodic background
-- reconciliation sweep (GET /api/auth/me no longer recomputes usage inline).
CREATE INDEX IF NOT EXISTS idx_files_user_size_active
ON storage.files (user_id) INCLUDE (size)
WHERE NOT is_trashed;
@@ -52,7 +52,12 @@ impl StorageUsageService {
}
/// Calculates a user's storage usage by summing all their file sizes.
/// Uses a direct SQL query for O(1) performance.
///
/// This is `SUM(size)` over the user's non-trashed files — O(number of
/// files), backed by the `idx_files_user_size_active` covering partial
/// index so it runs as an index-only scan. It is NOT called on the request
/// path; only by the per-upload update and the background reconciliation
/// sweep.
async fn calculate_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
debug!("Calculating storage for user: {}", user_id);
@@ -105,6 +110,37 @@ impl StorageUsageService {
Ok(total_usage)
}
/// Spawn a background task that periodically reconciles every user's cached
/// `storage_used_bytes` against the actual sum of their files.
///
/// `GET /api/auth/me` no longer recomputes usage on the request path; this
/// sweep (plus the per-upload update) keeps the cached value current for
/// all mutations — including deletes and trash — without any O(N) work on a
/// hot endpoint. Runs on the maintenance pool. The first sweep is deferred
/// by one interval so it never adds load at boot.
pub fn start_reconciliation_job(&self, interval_secs: u64) {
// Floor the interval so a misconfiguration can't busy-loop the sweep.
let interval_secs = interval_secs.max(30);
let service = self.clone();
info!(
"Starting storage-usage reconciliation job (every {}s)",
interval_secs
);
task::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
// tokio's first `tick()` fires immediately — consume it so the
// first real sweep happens one interval after startup.
ticker.tick().await;
loop {
ticker.tick().await;
debug!("Running scheduled storage-usage reconciliation");
if let Err(e) = service.update_all_users_storage_usage().await {
error!("Scheduled storage-usage reconciliation failed: {}", e);
}
}
});
}
}
/**
+14
View File
@@ -211,6 +211,11 @@ pub struct StorageConfig {
/// Maximum upload file size in bytes (default: 10 GB).
/// Applied as a hard limit to WebDAV PUT and streaming uploads.
pub max_upload_size: usize,
/// Interval (seconds) of the background sweep that reconciles every user's
/// cached `storage_used_bytes` with the real sum of their files. Keeps the
/// quota fresh for all mutations without recomputing on the request path.
/// Default: 600 (10 min). Env: `OXICLOUD_STORAGE_USAGE_RECONCILE_SECS`.
pub usage_reconcile_secs: u64,
/// Which blob storage backend to use (`local`, `s3`, or `azure`).
pub backend: StorageBackendType,
/// S3-compatible backend configuration (used when `backend == S3`).
@@ -348,6 +353,7 @@ impl Default for StorageConfig {
parallel_threshold: 100 * 1024 * 1024, // 100 MB
trash_retention_days: 30, // 30 days
max_upload_size: MAX_UPLOAD_SIZE,
usage_reconcile_secs: 600, // 10 minutes
backend: StorageBackendType::Local,
s3: None,
azure: None,
@@ -1212,6 +1218,14 @@ impl AppConfig {
config.storage.max_upload_size = val;
}
// Background storage-usage reconciliation interval
if let Ok(secs) =
env::var("OXICLOUD_STORAGE_USAGE_RECONCILE_SECS").map(|v| v.parse::<u64>())
&& let Ok(val) = secs
{
config.storage.usage_reconcile_secs = val;
}
// Storage backend selection
if let Ok(backend) = env::var("OXICLOUD_STORAGE_BACKEND") {
match backend.to_lowercase().as_str() {
+4
View File
@@ -633,6 +633,10 @@ impl AppServiceFactory {
user_repository,
),
);
// Keep cached storage usage fresh off the request path: GET /api/auth/me
// no longer recomputes the O(N) SUM per call; a periodic sweep does it
// instead (on the maintenance pool).
service.start_reconciliation_job(self.config.storage.usage_reconcile_secs);
tracing::info!("Storage usage service initialized");
service
}
@@ -411,7 +411,11 @@ impl UserRepository for UserPgRepository {
Ok(user)
}
/// Updates only the storage usage of a user
/// Updates only the storage usage of a user.
///
/// The `IS DISTINCT FROM` guard makes this a no-op when the value is
/// unchanged — which is the common case for the periodic reconciliation
/// sweep — so it produces no dead tuple and no WAL when nothing changed.
async fn update_storage_usage(
&self,
user_id: Uuid,
@@ -420,10 +424,10 @@ impl UserRepository for UserPgRepository {
sqlx::query(
r#"
UPDATE auth.users
SET
SET
storage_used_bytes = $2,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND storage_used_bytes IS DISTINCT FROM $2
"#,
)
.bind(user_id)
+8 -24
View File
@@ -434,7 +434,7 @@ pub async fn refresh_token(
Ok(response)
}
/// Return the authenticated user's profile, including live storage usage.
/// Return the authenticated user's profile, including cached storage usage.
#[utoipa::path(
get,
path = "/api/auth/me",
@@ -454,29 +454,13 @@ pub async fn get_current_user(
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// First, update the storage usage statistics
// IMPORTANT: We await the calculation to return updated data
if let Some(storage_usage_service) = state.storage_usage_service.as_ref() {
// Calculate storage synchronously (we await the result)
match storage_usage_service
.update_user_storage_usage(user_id)
.await
{
Ok(usage) => {
tracing::info!(
"Updated storage usage for user {}: {} bytes",
user_id,
usage
);
}
Err(e) => {
// Only log a warning, don't fail the entire request
tracing::warn!("Failed to update storage usage for user {}: {}", user_id, e);
}
}
}
// Now get the user data WITH the updated storage
// Storage usage is served from the cached `storage_used_bytes` column —
// it is NOT recomputed here. Recomputing on this hot endpoint meant an
// O(N) `SUM(size)` over all the user's files plus an `UPDATE` of
// `auth.users` on every single call (one of the most frequent endpoints).
// The cached value is kept current by the per-upload update and a periodic
// background reconciliation sweep
// (see `StorageUsageService::start_reconciliation_job`).
let user = auth_service
.auth_application_service
.get_user_by_id(user_id)
@@ -141,12 +141,10 @@ fn strip_username_prefix(path: &str) -> &str {
} else {
&path[pos + 1..]
}
} else if uuid::Uuid::parse_str(path).is_ok() {
path
} else {
if uuid::Uuid::parse_str(path).is_ok() {
path
} else {
""
}
""
}
}