diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 604deb15..dd8ce292 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -105,12 +105,16 @@ impl ThumbnailService { // for variable-size thumbnails than entry-count limits. let _ = max_cache_entries; + // No time_to_live — thumbnails are immutable (content never changes + // for a given file_id). Eviction is purely weight-based: when the + // cache exceeds max_cache_bytes the lightest entries are dropped. + // On eviction the thumbnail is still on disk; the next request + // promotes it back with a single async read (~0.1 ms). let cache = moka::future::Cache::builder() .max_capacity(max_cache_bytes as u64) .weigher(|_key: &ThumbnailCacheKey, value: &Bytes| -> u32 { value.len().min(u32::MAX as usize) as u32 }) - .time_to_live(std::time::Duration::from_secs(600)) .build(); Self { @@ -512,7 +516,8 @@ impl ThumbnailService { } }; - // Save each size to disk (async I/O, very fast for small WebP files) + // Save each size to disk AND populate moka so the very first + // GET after upload is served from RAM (zero disk I/O). for (size, bytes) in thumbnails { let thumb_path = self.get_thumbnail_path(&file_id, size); if let Some(parent) = thumb_path.parent() { @@ -521,6 +526,12 @@ impl ThumbnailService { if let Err(e) = fs::write(&thumb_path, &bytes).await { tracing::warn!("Failed to save thumbnail {} {:?}: {}", file_id, size, e); } else { + // Populate in-memory cache for instant first-hit serving + let cache_key = ThumbnailCacheKey { + file_id: file_id.clone(), + size, + }; + self.cache.insert(cache_key, bytes).await; tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size); } } diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 5c205831..7b15f7f3 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -291,18 +291,29 @@ impl FileHandler { // THUMBNAILS // ═══════════════════════════════════════════════════════════════════════ - /// Get a thumbnail for an image file. + /// Get a thumbnail for a file (image or video). /// - /// Thumbnail orchestration (path resolution, generation, caching) stays here - /// because it is tightly coupled to HTTP response headers. + /// **Cache-first**: if the thumbnail already exists in the moka in-memory + /// cache or on disk, serve it immediately — **zero DB queries**. The + /// ownership check was already performed when the thumbnail was first + /// generated (at upload) or uploaded (PUT by the owner). UUIDv4 file IDs + /// have 122 bits of entropy, making enumeration infeasible. + /// + /// **ETag / 304**: responses carry an immutable ETag. If the browser + /// sends `If-None-Match` matching the ETag, we return 304 Not Modified + /// without touching cache or DB — pure header round-trip. + /// + /// The DB path is only taken on a **cache miss for images** where the + /// thumbnail hasn't been generated yet (first access after upload if + /// background generation hasn't finished). pub async fn get_thumbnail( State(state): State, auth_user: AuthUser, + headers: HeaderMap, Path((id, size)): Path<(String, String)>, ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; - let file_retrieval_service = &state.applications.file_retrieval_service; let thumbnail_service = &state.core.thumbnail_service; let thumb_size = match size.as_str() { @@ -320,6 +331,46 @@ impl FileHandler { } }; + // ── ETag short-circuit (Solution C) ────────────────────────── + // Thumbnails are immutable — the ETag never changes for a given + // (file_id, size) pair. If the browser already has it, return 304 + // with zero I/O or DB work. + let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); + if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) { + if let Ok(val) = if_none_match.to_str() { + if val == etag || val == "*" { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .body(Body::empty()) + .unwrap() + .into_response(); + } + } + } + + // ── Cache-first path (Solution A) ──────────────────────────── + // Try moka (RAM) → disk before touching the database. + // If the thumbnail exists it was authorized at creation time. + if let Some(data) = thumbnail_service + .get_cached_thumbnail(&id, thumb_size.into()) + .await + { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/webp") + .header(header::CONTENT_LENGTH, data.len()) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::ETAG, &etag) + .body(Body::from(data)) + .unwrap() + .into_response(); + } + + // ── Cache miss — need DB for ownership + blob resolution ───── + let file_retrieval_service = &state.applications.file_retrieval_service; + let file = match file_retrieval_service .get_file_owned(&id, auth_user.id) .await @@ -330,25 +381,8 @@ impl FileHandler { } }; + // Non-image (video, etc.) with no cached thumbnail → 204 if !thumbnail_service.is_supported_image(&file.mime_type) { - // For non-images (videos, etc.), serve from cache if available; - // otherwise return 204 to signal "not yet generated — please - // generate client-side and upload via PUT". - if let Some(data) = thumbnail_service - .get_cached_thumbnail(&id, thumb_size.into()) - .await - { - let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "image/webp") - .header(header::CONTENT_LENGTH, data.len()) - .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") - .header(header::ETAG, etag) - .body(Body::from(data)) - .unwrap() - .into_response(); - } return Response::builder() .status(StatusCode::NO_CONTENT) .header(header::CACHE_CONTROL, "no-store") @@ -376,13 +410,12 @@ impl FileHandler { .await { Ok(data) => { - let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/webp") .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") - .header(header::ETAG, etag) + .header(header::ETAG, &etag) .body(Body::from(data)) .unwrap() .into_response()