perf: eliminate double disk read in thumbnail generation (read-once buffer)

This commit is contained in:
Dionisio
2026-02-26 00:32:02 +01:00
parent 71727faab4
commit fb4eaf2cf9
@@ -192,7 +192,11 @@ impl ThumbnailService {
} }
// 2. Generate thumbnail (CPU-bound, runs in spawn_blocking) // 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 { match self.generate_thumbnail(&original_owned, size).await {
Ok(bytes) => { Ok(bytes) => {
// Save to disk (best-effort — don't fail the request) // Save to disk (best-effort — don't fail the request)
@@ -241,16 +245,18 @@ impl ThumbnailService {
let max_dim = size.max_dimension(); let max_dim = size.max_dimension();
// Acquire semaphore permit — bounds peak RAM from concurrent decodes // Acquire semaphore permit — bounds peak RAM from concurrent decodes
let _permit = self let _permit = self.decode_semaphore.acquire().await
.decode_semaphore
.acquire()
.await
.map_err(|_| ThumbnailError::TaskError("Decode semaphore closed".into()))?; .map_err(|_| ThumbnailError::TaskError("Decode semaphore closed".into()))?;
// Run image processing in blocking thread pool // Run image processing in blocking thread pool
let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> { let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> {
// Safety check: read dimensions from headers only (no full decode) // Single read: load file once into memory, then work from the buffer
let (w, h) = image::ImageReader::open(&path) 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()))? .map_err(|e| ThumbnailError::ImageError(e.to_string()))?
.into_dimensions() .into_dimensions()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
@@ -261,8 +267,9 @@ impl ThumbnailService {
))); )));
} }
// Load image (full decode — now safe within semaphore + resolution guard) // Full decode from the same in-memory buffer (no 2nd disk read)
let img = image::open(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Calculate new dimensions preserving aspect ratio // Calculate new dimensions preserving aspect ratio
let (orig_width, orig_height) = (img.width(), img.height()); let (orig_width, orig_height) = (img.width(), img.height());
@@ -277,9 +284,9 @@ impl ThumbnailService {
// Adaptive filter: faster filters for smaller sizes where // Adaptive filter: faster filters for smaller sizes where
// quality difference vs Lanczos3 is imperceptible // quality difference vs Lanczos3 is imperceptible
let filter = match size { 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::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); let thumbnail = img.resize(new_width, new_height, filter);
@@ -311,10 +318,7 @@ impl ThumbnailService {
let _permit = match self.decode_semaphore.acquire().await { let _permit = match self.decode_semaphore.acquire().await {
Ok(p) => p, Ok(p) => p,
Err(_) => { Err(_) => {
tracing::warn!( tracing::warn!("Decode semaphore closed, skipping thumbnails for {}", file_id);
"Decode semaphore closed, skipping thumbnails for {}",
file_id
);
return; return;
} }
}; };
@@ -323,8 +327,13 @@ impl ThumbnailService {
// Single spawn_blocking: 1 read + 1 decode + 3 resize + 3 encode // Single spawn_blocking: 1 read + 1 decode + 3 resize + 3 encode
let results = tokio::task::spawn_blocking(move || { let results = tokio::task::spawn_blocking(move || {
// Safety check: read dimensions from headers only (no full decode) // Single read: load file once into memory
let (w, h) = image::ImageReader::open(&path) 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()))? .map_err(|e| ThumbnailError::ImageError(e.to_string()))?
.into_dimensions() .into_dimensions()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
@@ -335,8 +344,9 @@ impl ThumbnailService {
))); )));
} }
let img = // Full decode from the same in-memory buffer (no 2nd disk read)
image::open(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let (orig_w, orig_h) = (img.width(), img.height()); let (orig_w, orig_h) = (img.width(), img.height());
@@ -354,15 +364,18 @@ impl ThumbnailService {
}; };
let filter = match size { let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle, ThumbnailSize::Icon => FilterType::Triangle,
ThumbnailSize::Preview => FilterType::CatmullRom, ThumbnailSize::Preview => FilterType::CatmullRom,
ThumbnailSize::Large => FilterType::CatmullRom, ThumbnailSize::Large => FilterType::CatmullRom,
}; };
let thumb = img.resize(new_w, new_h, filter); let thumb = img.resize(new_w, new_h, filter);
let mut buf = Vec::new(); let mut buf = Vec::new();
thumb 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()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok((size, Bytes::from(buf))) Ok((size, Bytes::from(buf)))
@@ -375,11 +388,15 @@ impl ThumbnailService {
let thumbnails = match results { let thumbnails = match results {
Ok(Ok(t)) => t, Ok(Ok(t)) => t,
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!("Thumbnail generation failed for {}: {}", file_id, e); tracing::warn!(
"Thumbnail generation failed for {}: {}", file_id, e
);
return; return;
} }
Err(e) => { Err(e) => {
tracing::warn!("Thumbnail task panicked for {}: {}", file_id, e); tracing::warn!(
"Thumbnail task panicked for {}: {}", file_id, e
);
return; return;
} }
}; };