From fb4eaf2cf970382f756e2139336c5f00d96dd55e Mon Sep 17 00:00:00 2001 From: Dionisio Date: Thu, 26 Feb 2026 00:32:02 +0100 Subject: [PATCH] perf: eliminate double disk read in thumbnail generation (read-once buffer) --- .../services/thumbnail_service.rs | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 206115e5..4532ebe4 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -192,7 +192,11 @@ impl ThumbnailService { } // 2. Generate thumbnail (CPU-bound, runs in spawn_blocking) - tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id_owned, size); + tracing::info!( + "🎨 Generating thumbnail: {} {:?}", + file_id_owned, + size + ); match self.generate_thumbnail(&original_owned, size).await { Ok(bytes) => { // Save to disk (best-effort — don't fail the request) @@ -241,16 +245,18 @@ impl ThumbnailService { let max_dim = size.max_dimension(); // Acquire semaphore permit — bounds peak RAM from concurrent decodes - let _permit = self - .decode_semaphore - .acquire() - .await + let _permit = self.decode_semaphore.acquire().await .map_err(|_| ThumbnailError::TaskError("Decode semaphore closed".into()))?; // Run image processing in blocking thread pool let result = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { - // Safety check: read dimensions from headers only (no full decode) - let (w, h) = image::ImageReader::open(&path) + // Single read: load file once into memory, then work from the buffer + let data = std::fs::read(&path) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + + // Safety check: read dimensions from in-memory buffer (no 2nd I/O) + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)) + .with_guessed_format() .map_err(|e| ThumbnailError::ImageError(e.to_string()))? .into_dimensions() .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; @@ -261,8 +267,9 @@ impl ThumbnailService { ))); } - // Load image (full decode — now safe within semaphore + resolution guard) - let img = image::open(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Full decode from the same in-memory buffer (no 2nd disk read) + let img = image::load_from_memory(&data) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; // Calculate new dimensions preserving aspect ratio let (orig_width, orig_height) = (img.width(), img.height()); @@ -277,9 +284,9 @@ impl ThumbnailService { // Adaptive filter: faster filters for smaller sizes where // quality difference vs Lanczos3 is imperceptible let filter = match size { - ThumbnailSize::Icon => FilterType::Triangle, // 150px — max speed + ThumbnailSize::Icon => FilterType::Triangle, // 150px — max speed ThumbnailSize::Preview => FilterType::CatmullRom, // 400px — good balance - ThumbnailSize::Large => FilterType::CatmullRom, // 800px — sufficient quality + ThumbnailSize::Large => FilterType::CatmullRom, // 800px — sufficient quality }; let thumbnail = img.resize(new_width, new_height, filter); @@ -311,10 +318,7 @@ impl ThumbnailService { let _permit = match self.decode_semaphore.acquire().await { Ok(p) => p, Err(_) => { - tracing::warn!( - "Decode semaphore closed, skipping thumbnails for {}", - file_id - ); + tracing::warn!("Decode semaphore closed, skipping thumbnails for {}", file_id); return; } }; @@ -323,8 +327,13 @@ impl ThumbnailService { // Single spawn_blocking: 1 read + 1 decode + 3 resize + 3 encode let results = tokio::task::spawn_blocking(move || { - // Safety check: read dimensions from headers only (no full decode) - let (w, h) = image::ImageReader::open(&path) + // Single read: load file once into memory + let data = std::fs::read(&path) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + + // Safety check: read dimensions from in-memory buffer (no 2nd I/O) + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)) + .with_guessed_format() .map_err(|e| ThumbnailError::ImageError(e.to_string()))? .into_dimensions() .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; @@ -335,8 +344,9 @@ impl ThumbnailService { ))); } - let img = - image::open(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Full decode from the same in-memory buffer (no 2nd disk read) + let img = image::load_from_memory(&data) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; let (orig_w, orig_h) = (img.width(), img.height()); @@ -354,15 +364,18 @@ impl ThumbnailService { }; let filter = match size { - ThumbnailSize::Icon => FilterType::Triangle, + ThumbnailSize::Icon => FilterType::Triangle, ThumbnailSize::Preview => FilterType::CatmullRom, - ThumbnailSize::Large => FilterType::CatmullRom, + ThumbnailSize::Large => FilterType::CatmullRom, }; let thumb = img.resize(new_w, new_h, filter); let mut buf = Vec::new(); thumb - .write_to(&mut std::io::Cursor::new(&mut buf), ImageFormat::WebP) + .write_to( + &mut std::io::Cursor::new(&mut buf), + ImageFormat::WebP, + ) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; Ok((size, Bytes::from(buf))) @@ -375,11 +388,15 @@ impl ThumbnailService { let thumbnails = match results { Ok(Ok(t)) => t, Ok(Err(e)) => { - tracing::warn!("Thumbnail generation failed for {}: {}", file_id, e); + tracing::warn!( + "Thumbnail generation failed for {}: {}", file_id, e + ); return; } Err(e) => { - tracing::warn!("Thumbnail task panicked for {}: {}", file_id, e); + tracing::warn!( + "Thumbnail task panicked for {}: {}", file_id, e + ); return; } };