Optimize encryption, storage, and media streaming performance (#447)

- AES-256-GCM in-place decryption halves peak RAM in encrypted blob backend
- Offload crypto ≥64 KiB to spawn_blocking (unblocks async runtime)
- Fix off-by-one in encrypted range stream (end now exclusive)
- Collapse 3 DB round-trips for quota updates into 1 correlated UPDATE
- Set-based reconciliation sweep replaces per-user task spawning
- Eliminate entity re-read after file overwrite via RETURNING clause
- Lightbox streams video/photos inline instead of fetch→blob
This commit is contained in:
Dionisio Pozo
2026-06-10 14:53:55 +02:00
committed by GitHub
parent 26b396490c
commit fe0053bc79
10 changed files with 440 additions and 227 deletions
+4 -1
View File
@@ -95,7 +95,10 @@ pub trait BlobStorageBackend: Send + Sync + 'static {
/// Stream the full blob content in chunks.
fn get_blob_stream(&self, hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>>;
/// Stream a byte range of the blob (for HTTP Range requests / video seek).
/// Stream the byte range `[start, end)` of the blob (for HTTP Range
/// requests / video seek). `end` is **exclusive**; `None` means "to the
/// end of the blob". Callers translating inclusive HTTP Range headers
/// must pass `last_byte + 1`.
fn get_blob_range_stream(
&self,
hash: &str,
+5 -1
View File
@@ -286,6 +286,10 @@ pub trait FileWritePort: Send + Sync + 'static {
/// When `pre_computed_hash` is provided, the dedup service skips the
/// hash re-read — zero extra I/O beyond the initial spool.
/// Peak RAM: ~256 KB regardless of file size.
///
/// Returns `(new_blob_hash, updated_at_epoch)` — everything a caller
/// needs to rebuild the fresh entity/ETag from a `File` it already
/// holds, without re-reading the row it just updated.
async fn update_file_content_from_temp(
&self,
file_id: &str,
@@ -294,7 +298,7 @@ pub trait FileWritePort: Send + Sync + 'static {
content_type: Option<String>,
pre_computed_hash: Option<String>,
modified_at: Option<i64>,
) -> Result<String, DomainError>;
) -> Result<(String, i64), DomainError>;
/// Registers file metadata WITHOUT writing content to disk (write-behind).
///
@@ -319,7 +319,8 @@ impl FileUploadUseCase for FileUploadService {
&& let Some(file) = file_read.find_file_by_path(path).await?
{
let file_id = file.id().to_string();
self.file_write
let (new_hash, updated_at) = self
.file_write
.update_file_content_from_temp(
&file_id,
temp_path,
@@ -333,8 +334,26 @@ impl FileUploadUseCase for FileUploadService {
if let Some(cc) = &self.content_cache {
cc.invalidate(&file_id).await;
}
// Re-read to get fresh DTO with updated etag and timestamps.
let updated = file_read.get_file(&file_id).await?;
// Rebuild the fresh DTO from the entity already in hand plus the
// values the UPDATE just returned — a re-read would only fetch
// what we already know, at one extra round-trip per overwrite
// (WebDAV sync clients overwrite constantly).
let parts = file.into_parts();
let updated = crate::domain::entities::file::File::with_timestamps_and_blob_hash(
parts.id,
parts.name,
parts.storage_path,
size,
parts.mime_type,
parts.folder_id,
parts.created_at,
updated_at as u64,
parts.owner_id,
new_hash,
)
.map_err(|e| {
DomainError::internal_error("FileUpload", format!("rebuild entity: {e}"))
})?;
let dto = FileDto::from(updated);
if let Some(hook) = &self.file_lifecycle_hook {
hook.on_file_updated(&file_id, &dto.etag, content_type);
@@ -213,8 +213,8 @@ impl FileWritePort for MockFileWritePort {
_content_type: Option<String>,
_pre_computed_hash: Option<String>,
_modified_at: Option<i64>,
) -> Result<String, DomainError> {
Ok(String::new())
) -> Result<(String, i64), DomainError> {
Ok((String::new(), 0))
}
async fn register_file_deferred(
@@ -31,19 +31,36 @@ impl StorageUsageService {
}
}
/// Calculates and updates storage usage for a specific user
/// Recalculates and stores one user's usage in a single statement.
///
/// The correlated `SUM(size)` over the user's non-trashed files is
/// O(number of files) but runs as an index-only scan on the
/// `idx_files_user_size_active` covering partial index. One round-trip
/// (was three: user lookup + SUM + UPDATE). NOT called on the request
/// path — only by the per-upload background update and the sweep.
pub async fn update_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
info!("Updating storage usage for user: {}", user_id);
let total_usage: Option<i64> = sqlx::query_scalar(
r#"
UPDATE auth.users u
SET storage_used_bytes = COALESCE((
SELECT SUM(f.size)::bigint
FROM storage.files f
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
WHERE u.id = $1
RETURNING u.storage_used_bytes
"#,
)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("StorageUsage", format!("Failed to update usage: {e}"))
})?;
// Calculate storage usage directly from database
let total_usage = self.calculate_user_storage_usage(user_id).await?;
let total_usage = total_usage
.ok_or_else(|| DomainError::not_found("User", format!("User ID: {user_id}")))?;
// Update the user's storage usage in the database
self.user_repository
.update_storage_usage(user_id, total_usage)
.await?;
info!(
debug!(
"Updated storage usage for user {} to {} bytes",
user_id, total_usage
);
@@ -51,61 +68,35 @@ impl StorageUsageService {
Ok(total_usage)
}
/// Calculates a user's storage usage by summing all their file sizes.
///
/// 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);
// Direct SQL query to sum all file sizes for this user
// This is much more efficient than recursively walking folders
let total_size: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE(SUM(size), 0)::bigint
FROM storage.files
WHERE user_id = $1 AND NOT is_trashed
"#,
)
.bind(user_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("StorageUsage", format!("Failed to calculate usage: {e}"))
})?;
debug!(
"Calculated storage for user {}: {} bytes",
user_id, total_size
);
Ok(total_size)
}
/// Calculates and updates storage usage for a user identified by username.
/// Same as [`Self::update_user_storage_usage`], keyed by username.
pub async fn update_user_storage_usage_by_username(
&self,
username: &str,
) -> Result<i64, DomainError> {
info!("Updating storage usage for username: {}", username);
let total_usage: Option<i64> = sqlx::query_scalar(
r#"
UPDATE auth.users u
SET storage_used_bytes = COALESCE((
SELECT SUM(f.size)::bigint
FROM storage.files f
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
WHERE u.username = $1
RETURNING u.storage_used_bytes
"#,
)
.bind(username)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("StorageUsage", format!("Failed to update usage: {e}"))
})?;
let user = self.user_repository.get_user_by_username(username).await?;
let user_id = user.id();
let total_usage =
total_usage.ok_or_else(|| DomainError::not_found("User", username.to_string()))?;
// Reuse the existing calculation logic
let total_usage = self.calculate_user_storage_usage(user_id).await?;
// Update the user's storage usage in the database
self.user_repository
.update_storage_usage(user_id, total_usage)
.await?;
info!(
"Updated storage usage for username {} (id={}) to {} bytes",
username, user_id, total_usage
debug!(
"Updated storage usage for username {} to {} bytes",
username, total_usage
);
Ok(total_usage)
@@ -159,49 +150,49 @@ impl StorageUsagePort for StorageUsageService {
StorageUsageService::update_user_storage_usage_by_username(self, username).await
}
/// Reconcile every internal user's cached usage in ONE set-based UPDATE.
///
/// Replaces the previous shape (paginated user list + one spawned task
/// per user, each issuing SUM + UPDATE — up to 2N queries and N
/// concurrent tasks fighting for pool connections). A single GROUP BY
/// over the covering index feeds all users at once, and the
/// `IS DISTINCT FROM` guard skips rewriting rows whose value didn't
/// change (no dead-tuple churn for idle users). This also removes the
/// old `LIMIT 1000` page cap, which silently left users beyond the
/// first thousand unreconciled.
///
/// External users are excluded — they carry no storage by construction
/// (DB CHECK `users_external_no_storage`).
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
info!("Starting batch update of all users' storage usage");
debug!("Starting storage-usage reconciliation sweep");
// Get the list of all users
// include_external=false — external users carry no storage by
// construction (DB CHECK `users_external_no_storage`), so there's
// nothing to compute for them.
let users = self.user_repository.list_users(1000, 0, false).await?;
let result = sqlx::query(
r#"
UPDATE auth.users u
SET storage_used_bytes = COALESCE(t.total, 0)
FROM auth.users u2
LEFT JOIN (
SELECT user_id, SUM(size)::bigint AS total
FROM storage.files
WHERE NOT is_trashed
GROUP BY user_id
) t ON t.user_id = u2.id
WHERE u.id = u2.id
AND NOT u2.is_external
AND u.storage_used_bytes IS DISTINCT FROM COALESCE(t.total, 0)
"#,
)
.execute(self.pool.as_ref())
.await
.map_err(|e| {
error!("Storage-usage reconciliation sweep failed: {}", e);
DomainError::internal_error("StorageUsage", format!("reconciliation sweep: {e}"))
})?;
let mut update_tasks = Vec::new();
// Process users in parallel
for user in users {
let user_id = user.id();
let service_clone = self.clone();
// Spawn a background task for each user
let task = task::spawn(async move {
match service_clone.update_user_storage_usage(user_id).await {
Ok(usage) => {
debug!(
"Updated storage usage for user {}: {} bytes",
user_id, usage
);
Ok(())
}
Err(e) => {
error!("Failed to update storage for user {}: {}", user_id, e);
Err(e)
}
}
});
update_tasks.push(task);
}
// Wait for all tasks to complete
for task in update_tasks {
// We don't propagate errors from individual users to avoid failing the entire batch
let _ = task.await;
}
info!("Completed batch update of all users' storage usage");
info!(
"Storage-usage reconciliation corrected {} user(s)",
result.rows_affected()
);
Ok(())
}
@@ -594,8 +594,8 @@ impl FileWritePort for MockFileRepository {
_content_type: Option<String>,
_pre_computed_hash: Option<String>,
_modified_at: Option<i64>,
) -> std::result::Result<String, DomainError> {
Ok(String::new())
) -> std::result::Result<(String, i64), DomainError> {
Ok((String::new(), 0))
}
async fn register_file_deferred(