Files
Oxicloud/PERFORMANCE_AUDIT.md
T
Diocrafts 2dd3dc0b54 fix: prevent blob storage leak on folder deletion
- Add PG trigger trg_files_decrement_blob_ref (AFTER DELETE ON storage.files)
  that auto-decrements storage.blobs.ref_count for every deleted file row.
  Covers all paths: explicit DELETE, ON DELETE CASCADE, trash emptying.

- Remove manual remove_reference() call from delete_file() in
  file_blob_write_repository — trigger is now the single source of truth.

- Fix double-decrement bug in FileManagementService::delete_with_cleanup:
  was decrementing ref_count on trash (soft-delete) when the file row still
  existed, causing premature blob GC and potential data corruption on restore.

- Remove dead fields (file_read, dedup_service) from FileManagementService
  and simplify constructors — ref_count fully handled by PG trigger.
2026-02-22 22:07:46 +01:00

28 KiB
Raw Blame History

OxiCloud — Comprehensive Architecture & Performance Audit

Scope: Full source-level analysis of all layers (domain → infrastructure → application → interfaces). Methodology: Static analysis of every critical .rs file. No code changes made.


Table of Contents

  1. CRITICAL — std::sync::Mutex Blocking the Tokio Runtime
  2. CRITICAL — ZIP Service Loads Entire Files into Memory
  3. CRITICAL — Share Repository: JSON File I/O per Operation
  4. HIGH — Blocking Filesystem Calls in Async Context
  5. HIGH — Unbounded Task Spawning in Recursive Search
  6. HIGH — Thumbnail Cache Write-Lock Contention on Reads
  7. HIGH — HTTP Cache Middleware Buffers Entire Response Bodies
  8. MEDIUM — N+1 Queries / Extra Database Round Trips
  9. MEDIUM — Unnecessary String Allocations in Error Paths
  10. MEDIUM — Redundant Path String in Domain Entities
  11. MEDIUM — Unbounded Parallel Tasks in Storage Usage Update
  12. MEDIUM — Upload Handler Re-parses Its Own HTTP Response
  13. LOW — One-Shot Cache Pattern Defeats Caching Purpose
  14. LOW — Duplicated SQL in Paginated Search
  15. LOW — Sequential Trash Cleanup Without Batching
  16. LOW — Search Cache Key Serializes Entire DTO to JSON
  17. Positive Patterns — What's Done Well
  18. Summary Matrix

1. CRITICAL — std::sync::Mutex Blocking the Tokio Runtime

Location

File Line(s) Symbol
src/application/services/search_service.rs 4, 56 search_cache: Arc<Mutex<HashMap<…>>>
src/interfaces/middleware/cache.rs 14, 51 cache: Arc<Mutex<HashMap<…>>>
src/application/services/auth_application_service.rs 62–63 pending_oidc_flows, pending_oidc_tokens

Problematic Pattern

// search_service.rs:4
use std::sync::Mutex;

// search_service.rs:56
search_cache: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,

Every call to .lock() on a std::sync::Mutex across an .await boundary blocks the entire Tokio worker thread. If all Tokio workers are blocked on the Mutex simultaneously, the runtime deadlocks.

In search_service.rs, get_from_cache() and store_in_cache() both call .lock(), and store_in_cache() does eviction work (iteration + removal) while holding the lock. The cleanup task (start_cache_cleanup_task) also locks the Mutex inside a tokio::spawn future.

In cache.rs, every HTTP GET request passes through .get() or .set(), each calling self.cache.lock().unwrap(). The evict_oldest() method sorts all entries by timestamp while the parent lock is held.

Impact

  • Severity: CRITICAL under concurrent load.
  • Under 50+ concurrent requests, Tokio worker threads park on the Mutex, causing tail-latency spikes (p99 > 100ms) and potential deadlock.
  • The cleanup tasks also lock, creating periodic contention peaks.

Fix Sketch

Option A — Replace with tokio::sync::RwLock (minimal change):

use tokio::sync::RwLock;
search_cache: Arc<RwLock<HashMap<SearchCacheKey, CachedSearchResult>>>,

Option B — Replace with moka (lock-free, recommended):

use moka::future::Cache;

// In SearchService
search_cache: Cache<SearchCacheKey, SearchResultsDto>,

// Construction
let search_cache = Cache::builder()
    .max_capacity(max_cache_size as u64)
    .time_to_live(Duration::from_secs(cache_ttl))
    .build();

This eliminates all manual eviction logic and the cleanup task entirely. Already used successfully in image_transcode_service.rs and file_content_cache.rs.

For the HTTP cache middleware: consider replacing with moka::future::Cache<String, CacheEntry>.

For the auth service: the OIDC maps are short-lived and low-contention, so tokio::sync::Mutex would suffice, or use dashmap::DashMap.


2. CRITICAL — ZIP Service Loads Entire Files into Memory

Location

File Line(s) Symbol
src/infrastructure/services/zip_service.rs 209–230 add_file_to_zip()

Problematic Pattern

// zip_service.rs: add_file_to_zip()
async fn add_file_to_zip(
    &self,
    zip: &mut ZipWriter<Cursor<Vec<u8>>>,
    // ...
) -> Result<()> {
    // Loads ENTIRE file content into memory
    let content = self.file_service.get_file_content(&file_id).await?;
    zip.write_all(&content)?;
}

For a folder download containing N files of size S, memory usage is O(N × S) plus the ZIP buffer itself. A folder with 100 × 100MB files = 10GB in RAM.

Impact

  • Severity: CRITICAL for large folders. OOM-kill risk in production.
  • The ZIP buffer (Cursor<Vec<u8>>) also holds the entire compressed output in memory.

Fix Sketch

Use tokio::io::AsyncRead + streaming ZIP writer (e.g., async_zip crate):

// Stream-based approach:
let blob_stream = self.dedup_service.read_blob_stream(&blob_hash).await?;
// Pipe directly to zip writer without buffering the entire file
zip.write_entry_stream(file_name, blob_stream).await?;

Alternatively, use read_blob_stream() (which already exists in DedupService with 256KB chunks) and write chunks incrementally to the ZIP.


3. CRITICAL — Share Repository: JSON File I/O per Operation

Location

File Line(s) Symbol
src/infrastructure/repositories/share_fs_repository.rs 1–286 ShareFsRepository

Problematic Pattern

// Every read operation:
async fn get_share(&self, id: &str) -> Result<…> {
    let shares = self.read_shares().await?;  // Read ENTIRE file
    shares.into_iter().find(|s| s.id == id)  // Linear scan
}

// Every write operation:
async fn create_share(&self, share: Share) -> Result<…> {
    let mut shares = self.read_shares().await?;  // Read ENTIRE file
    shares.push(share);
    self.write_shares(&shares).await?;            // Write ENTIRE file
}

Impact

  • Severity: CRITICAL for concurrent users.
  • Race condition: Two concurrent create_share() calls read the same file, each appends its share, and the second write loses the first share.
  • O(n) per operation — every read scans all shares.
  • Blocking I/O: tokio::fs::read / tokio::fs::write are async but the entire file is serialized/deserialized on every call.

Fix Sketch

Option A — Migrate to PostgreSQL (recommended, consistent with other repos):

CREATE TABLE storage.shares (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    resource_id UUID NOT NULL,
    resource_type TEXT NOT NULL,
    token TEXT UNIQUE NOT NULL,
    password_hash TEXT,
    expires_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_shares_token ON storage.shares(token);

Option B — Add file locking + in-memory index (minimal change):

struct ShareFsRepository {
    shares: Arc<RwLock<HashMap<String, Share>>>,  // In-memory index
    path: PathBuf,
    file_lock: tokio::sync::Mutex<()>,            // Serialize writes
}

4. HIGH — Blocking Filesystem Calls in Async Context

Location

File Line(s) Symbol
src/infrastructure/services/path_service.rs 137, 148, 165, 172 physical_path.exists(), .is_file(), .is_dir()
src/main.rs 60, 64 std::fs::create_dir_all()
src/infrastructure/services/dedup_service.rs 224 std::fs::remove_file()

Problematic Pattern

// path_service.rs — inside async fn file_exists()
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
    let physical_path = self.resolve_path(storage_path);
    let exists = physical_path.exists() && physical_path.is_file();  // BLOCKING
    Ok(exists)
}

// Also in directory_exists() and ensure_directory()

Path::exists(), .is_file(), and .is_dir() perform synchronous stat() syscalls. On network-attached storage (NFS, CIFS) or slow disks, these can take 10–100ms, blocking a Tokio worker.

Impact

  • Severity: HIGH on NFS/CIFS storage; moderate on local SSD.
  • path_service.rs is called by the StoragePort trait used throughout the application.

Fix Sketch

async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
    let physical_path = self.resolve_path(storage_path);
    match tokio::fs::metadata(&physical_path).await {
        Ok(meta) => Ok(meta.is_file()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(DomainError::from(e)),
    }
}

For main.rs (startup-only), the blocking calls are acceptable but could use tokio::fs::create_dir_all() for consistency.


Location

File Line(s) Symbol
src/application/services/search_service.rs 310–360 search_parallel()

Problematic Pattern

fn search_parallel(…) -> Pin<Box<dyn Future<…> + Send>> {
    Box::pin(async move {
        let folders = folder_repo.list_folders(current_folder_id.as_deref()).await?;
        
        // Spawns one task PER subfolder — NO concurrency limit
        let mut handles = Vec::with_capacity(folder_dtos.len());
        for subfolder in &folder_dtos {
            handles.push(tokio::spawn(async move {
                Self::search_parallel(fr, fdr, Some(folder_id), crit).await
            }));
        }
        // ...joins all
    })
}

For a directory tree of depth D with branching factor B, this spawns B^D tasks. A user with 1000 folders in a flat structure spawns 1000 concurrent tasks, each making DB queries.

Impact

  • Severity: HIGH — DB connection pool exhaustion (max 20 connections), Tokio task backlog.
  • Contrast with batch_operations.rs which correctly uses Semaphore::new(10).

Fix Sketch

use tokio::sync::Semaphore;

fn search_parallel(
    semaphore: Arc<Semaphore>,
    // ... other args
) {
    Box::pin(async move {
        let _permit = semaphore.acquire().await.unwrap();
        // ... existing logic, pass semaphore to recursive calls
    })
}

Or better: for non-recursive search, the DB-level pagination path is already used. For recursive search, consider a single recursive SQL CTE instead of application-level recursion.


6. HIGH — Thumbnail Cache Write-Lock Contention on Reads

Location

File Line(s) Symbol
src/infrastructure/services/thumbnail_service.rs ~25, 200–280 cache: Arc<RwLock<LruCache<…>>>

Problematic Pattern

// LruCache requires write access on EVERY read (LRU promotion)
pub async fn get_thumbnail(&self, …) -> Result<Bytes, …> {
    // Read from cache — but LRU needs write lock!
    let cache = self.cache.read().await;  // Can't actually use read lock for LRU
    // ...
}

pub async fn add_to_cache(&self, key: ThumbnailCacheKey, data: Bytes) {
    let mut current_size = self.current_cache_bytes.write().await;  // Lock #1
    // ... eviction loop also acquires:
    let mut cache = self.cache.write().await;                       // Lock #2
    // TWO write locks held simultaneously
}

Every cache hit and miss requires a write lock. Under concurrent image requests, this creates a bottleneck.

Impact

  • Severity: HIGH for image-heavy workloads.
  • Two separate RwLock acquisitions in add_to_cache() — potential for deadlock if ordering is inconsistent.

Fix Sketch

Replace with moka::future::Cache (already used in image_transcode_service.rs):

use moka::future::Cache;

pub struct ThumbnailService {
    cache: Cache<ThumbnailCacheKey, Bytes>,  // Lock-free reads, weight-based eviction
    // Remove current_cache_bytes — moka tracks weight internally
}

// Construction
let cache = Cache::builder()
    .max_capacity(max_cache_bytes as u64)
    .weigher(|_k: &ThumbnailCacheKey, v: &Bytes| v.len() as u32)
    .time_to_idle(Duration::from_secs(300))
    .build();

7. HIGH — HTTP Cache Middleware Buffers Entire Response Bodies

Location

File Line(s) Symbol
src/interfaces/middleware/cache.rs 247–259, 450–470 cache_middleware(), HttpCacheService::call()

Problematic Pattern

// cache.rs: cache_middleware()
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10)  // Buffer up to 10MB
    .await
    .unwrap_or_default();

let etag = cache.calculate_etag_for_bytes(&bytes);  // Hash all bytes
cache.set(cache_key, etag, Some(bytes.clone()), …);  // Clone + store

Every non-cached GET response is fully buffered to calculate an ETag, even for responses that shouldn't be cached (large file listings, etc.).

Impact

  • Severity: HIGH — 10MB max buffer per concurrent request × N concurrent requests.
  • The bytes.clone() doubles peak memory per response.

Fix Sketch

  1. Only cache small responses (check Content-Length first).
  2. Use streaming hash (SHA-256) to compute ETag without buffering.
  3. Skip caching for responses > 1MB.
  4. Replace std::sync::Mutex backing the cache (see Issue #1).

8. MEDIUM — N+1 Queries / Extra Database Round Trips

Location

File Line(s) Symbol Issue
src/infrastructure/repositories/pg/folder_db_repository.rs ~rename_folder rename_folder() UPDATE + separate SELECT
src/infrastructure/repositories/pg/folder_db_repository.rs ~move_folder move_folder() UPDATE + separate SELECT
src/infrastructure/repositories/pg/file_blob_write_repository.rs ~lookup_folder_path lookup_folder_path() Extra query per file write
src/infrastructure/repositories/pg/trash_db_repository.rs ~clear_trash clear_trash() 2 separate DELETEs

Problematic Pattern

// folder_db_repository.rs: rename_folder()
async fn rename_folder(&self, id: &str, new_name: &str) -> Result<Folder, DomainError> {
    // Query 1: UPDATE
    sqlx::query("UPDATE storage.folders SET name = $1 WHERE id = $2::uuid")
        .execute(self.pool.as_ref()).await?;
    
    // Query 2: SELECT (separate round trip)
    self.get_folder(id).await
}

Impact

  • Severity: MEDIUM — adds 1–5ms per extra round trip, compounded in batch operations.
  • lookup_folder_path() is called per file write; in batch uploads of N files to the same folder, it makes N identical queries.

Fix Sketch

-- Use RETURNING to get the updated row in a single query
UPDATE storage.folders SET name = $1
WHERE id = $2::uuid
RETURNING id::text, name, parent_id::text, path, …

For lookup_folder_path() in batch operations, cache the folder path for the duration of the batch.


9. MEDIUM — Unnecessary String Allocations in Error Paths

Location

File Line(s) Symbol
src/domain/errors.rs throughout DomainError factory methods
src/infrastructure/repositories/pg/*.rs throughout `.map_err(

Problematic Pattern

// domain/errors.rs
pub fn not_found(entity_type: &'static str, id: impl Into<String>) -> Self {
    let entity_id = id.into();
    Self {
        message: format!("{} not found: {}", entity_type, entity_id),  // ALLOCATION
        entity_id: Some(entity_id),                                     // ALLOCATION
        // ...
    }
}

Every error — even NotFound which may be a normal control flow path (e.g., checking if a file exists) — allocates 2 strings via format!() and into().

Impact

  • Severity: MEDIUM — hot error paths (404 checks, duplicate detection) trigger allocations.
  • In batch operations checking 1000 files, this creates thousands of unnecessary allocations.

Fix Sketch

Use Cow<'static, str> for common messages:

pub fn not_found(entity_type: &'static str, id: impl Into<String>) -> Self {
    Self {
        message: Cow::Borrowed(""),  // Defer formatting to Display impl
        entity_id: Some(id.into()),
        kind: ErrorKind::NotFound,
        entity_type,
        source: None,
    }
}

impl fmt::Display for DomainError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Format lazily only when displayed
        write!(f, "{} {}: {}", self.entity_type, self.kind, 
               self.entity_id.as_deref().unwrap_or("unknown"))
    }
}

10. MEDIUM — Redundant Path String in Domain Entities

Location

File Line(s) Symbol
src/domain/entities/file.rs ~30–50 storage_path: StoragePath + path_string: String
src/domain/entities/folder.rs ~30–50 Same pattern

Problematic Pattern

pub struct File {
    storage_path: StoragePath,
    path_string: String,  // Redundant: same data as storage_path.to_string()
    // ...
}

Every File and Folder entity carries both a StoragePath (which internally holds Vec<String>) and a pre-rendered String copy. This doubles the path memory per entity.

Impact

  • Severity: MEDIUM — when listing 10,000 files, each path is stored twice.

Fix Sketch

Remove path_string and derive it on demand:

impl File {
    pub fn path_string(&self) -> String {
        self.storage_path.to_string()
    }
}

Or cache it lazily with OnceCell<String> if .to_string() is called frequently.


11. MEDIUM — Unbounded Parallel Tasks in Storage Usage Update

Location

File Line(s) Symbol
src/application/services/storage_usage_service.rs 138–165 update_all_users_storage_usage()

Problematic Pattern

async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
    let users = self.user_repository.list_users(1000, 0).await?;
    
    let mut update_tasks = Vec::new();
    for user in users {
        let service_clone = self.clone();
        // Spawn one task per user — NO concurrency limit
        let task = task::spawn(async move {
            service_clone.update_user_storage_usage(&user_id).await
        });
        update_tasks.push(task);
    }
    // joins all
}

Impact

  • Severity: MEDIUM — 1000 users = 1000 concurrent DB queries. DB pool has max 20 connections, so 980 tasks queue, but Tokio task overhead + connection wait time is wasteful.

Fix Sketch

use futures::stream::{self, StreamExt};

stream::iter(users)
    .map(|user| {
        let svc = self.clone();
        async move { svc.update_user_storage_usage(&user.id).await }
    })
    .buffer_unordered(10)  // Max 10 concurrent
    .collect::<Vec<_>>()
    .await;

12. MEDIUM — Upload Handler Re-parses Its Own HTTP Response

Location

File Line(s) Symbol
src/interfaces/api/handlers/file_handler.rs ~upload_file_with_thumbnails upload_file_with_thumbnails()

Problematic Pattern

The upload_file_with_thumbnails handler calls the upload logic, gets back an HTTP response, then reads the response body back to extract the file ID for thumbnail generation. This means:

  1. Serialize file info → JSON response body
  2. Read response body → bytes
  3. Deserialize bytes → file info
  4. Use file info for thumbnail generation

Impact

  • Severity: MEDIUM — unnecessary serialize → deserialize round trip per upload.

Fix Sketch

Call the upload service directly and pass the result to thumbnail generation, instead of going through HTTP serialization:

let file = upload_service.upload_file(…).await?;
thumbnail_service.generate_all_sizes_background(file.id.clone(), path);
Ok(Json(FileDto::from(file)))

13. LOW — One-Shot Cache Pattern Defeats Caching Purpose

Location

File Line(s) Symbol
src/infrastructure/repositories/pg/file_blob_read_repository.rs ~95–115 resolve_blob_hash()

Problematic Pattern

async fn resolve_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
    // Check moka cache
    if let Some(hash) = self.hash_cache.get(file_id) {
        self.hash_cache.invalidate(file_id);  // Immediately invalidate!
        return Ok(hash);
    }
    // ... DB query
}

The hash is cached then immediately invalidated after first use. This means repeated reads of the same file always hit the database.

Impact

  • Severity: LOW — the pattern only provides "write-behind" benefit (avoiding a DB query between upload and first download). Repeated downloads bypass cache.

Fix Sketch

Remove the invalidation and let moka's TTI (30s) handle expiry:

if let Some(hash) = self.hash_cache.get(file_id) {
    return Ok(hash);  // Let TTI handle expiry
}

Location

File Line(s) Symbol
src/infrastructure/repositories/pg/file_blob_read_repository.rs 400–620 search_files_paginated()

Problematic Pattern

Four nearly identical match arms containing:

  • Copy-pasted SQL with minor WHERE clause differences
  • Each arm has a COUNT query + SELECT query (2 DB round trips per search)
  • SQL ORDER BY built via format!() string interpolation

Impact

  • Severity: LOW (correctness) to MEDIUM (maintenance burden).
  • The COUNT query is always executed even when the result set is smaller than the limit (i.e., total count could be inferred).

Fix Sketch

Build the query dynamically with a query builder:

let mut conditions = vec!["fi.user_id = $1::uuid", "fi.is_trashed = false"];
let mut bind_idx = 2;

if let Some(fid) = folder_id {
    conditions.push(&format!("fi.folder_id = ${bind_idx}::uuid"));
    bind_idx += 1;
}
if let Some(name) = &criteria.name_contains {
    conditions.push(&format!("LOWER(fi.name) LIKE ${bind_idx}"));
    bind_idx += 1;
}
// ... single query construction

Use COUNT(*) OVER() window function to get total count in a single query:

SELECT fi.*, COUNT(*) OVER() as total_count
FROM storage.files fi
WHERE …
ORDER BY … LIMIT $N OFFSET $M

15. LOW — Sequential Trash Cleanup Without Batching

Location

File Line(s) Symbol
src/infrastructure/services/trash_cleanup_service.rs 75–95 cleanup_expired_items()

Problematic Pattern

for item in expired_items {
    trash_service.delete_permanently(&trash_id, &user_id).await;  // One at a time
}

Impact

  • Severity: LOW — cleanup runs periodically in the background, not in the request path.

Fix Sketch

Use futures::stream::buffer_unordered() for concurrent deletion, or batch-delete at the SQL level:

DELETE FROM storage.files WHERE is_trashed = true AND trashed_at < NOW() - INTERVAL '30 days';

16. LOW — Search Cache Key Serializes Entire DTO to JSON

Location

File Line(s) Symbol
src/application/services/search_service.rs 170–180 create_cache_key()

Problematic Pattern

fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> Result<SearchCacheKey> {
    let criteria_str = serde_json::to_string(criteria).map_err(…)?;  // Full JSON serialization
    Ok(SearchCacheKey {
        criteria_hash: criteria_str,  // Stored as full JSON string, not a hash
        user_id: user_id.to_string(),
    })
}

The "hash" field is actually the full JSON string, not a hash. This means:

  1. Full serde serialization per search request
  2. HashMap key comparison is O(n) on string length
  3. Unnecessary memory for cache keys

Impact

  • Severity: LOW — search requests are human-speed, not high-throughput.

Fix Sketch

use std::hash::{Hash, Hasher, DefaultHasher};

fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
    let mut hasher = DefaultHasher::new();
    criteria.hash(&mut hasher);  // Derive Hash on SearchCriteriaDto
    user_id.hash(&mut hasher);
    SearchCacheKey(hasher.finish())
}

17. Positive Patterns — What's Done Well

These are worth calling out as exemplary implementations:

Component File Pattern
Image Transcoding image_transcode_service.rs Dedicated rayon thread pool (not Tokio blocking pool), moka lock-free cache, AtomicU64 stats, fire-and-forget disk cache writes. Best-in-class design.
File Content Cache file_content_cache.rs moka weight-based cache, lock-free reads, automatic eviction. Clean.
Batch Operations batch_operations.rs Semaphore-based concurrency control. Correct pattern.
Thumbnail Generation thumbnail_service.rs Uses spawn_blocking for image processing. Correct (but cache should be moka).
Compression compression_service.rs spawn_blocking for CPU-bound gzip. Streaming compress. Correct.
Dedup Service dedup_service.rs Atomic write via temp+rename, SELECT FOR UPDATE for blob refcounting, 2-char hash prefix sharding. Solid CAS implementation.
Multi-Tier Download file_retrieval_service.rs Write-behind → hot cache + WebP → mmap → streaming. Well-designed tiered strategy.
Streaming Upload file_handler.rs SHA-256 computed during spool, 512KB BufWriter, pre-allocation hints. Good.
DB Pagination file_blob_read_repository.rs Non-recursive search uses LIMIT/OFFSET at DB level. Correct.
Content Dedup file_blob_write_repository.rs copy_file uses CTE for zero-copy blob dedup. update_file_content uses atomic CTE with FOR UPDATE.

18. Summary Matrix

# Issue Severity Impact Area Effort to Fix
1 std::sync::Mutex in async CRITICAL Latency, deadlock Small (swap to moka)
2 ZIP loads files into memory CRITICAL OOM risk Medium (streaming ZIP)
3 Share repo: JSON file I/O CRITICAL Data loss, O(n) Medium (migrate to PG)
4 Blocking FS in async HIGH Latency on slow storage Small (use tokio::fs)
5 Unbounded search tasks HIGH DB pool exhaustion Small (add Semaphore)
6 Thumbnail cache write-lock HIGH Contention Small (swap to moka)
7 HTTP cache buffers 10MB HIGH Memory Medium (streaming hash)
8 N+1 queries MEDIUM Latency Small (use RETURNING)
9 Error string allocations MEDIUM Allocator pressure Medium (Cow/lazy)
10 Redundant path string MEDIUM Memory Small (remove field)
11 Unbounded storage tasks MEDIUM DB pool Small (add Semaphore)
12 Handler re-parses response MEDIUM CPU waste Small (refactor)
13 One-shot cache invalidation LOW Cache miss rate Trivial
14 Duplicated search SQL LOW Maintenance Medium
15 Sequential trash cleanup LOW Cleanup speed Small
16 JSON cache key LOW Minor alloc Small
  1. Issues 1, 2, 3 — Fix immediately. These can cause production incidents (deadlocks, OOM, data loss).
  2. Issues 4, 5, 6 — Fix before scaling. These create bottlenecks under load.
  3. Issue 7 — Fix when observing memory pressure.
  4. Issues 8–12 — Address as part of normal development.
  5. Issues 13–16 — Clean up opportunistically.