perf(thumbnails): hold a decode permit before reading source blobs

Request-path thumbnail generation (REST get_thumbnail and NC preview)
read the full source blob into RAM and decoded it with no concurrency
bound — a first-view gallery of K images stacked K full-size buffers
and K parallel decodes on the blocking pool. The background hook had
the inverse ordering problem: it read the blob eagerly and only then
queued on the decode semaphore, so N concurrent uploads held N
originals in memory while waiting.

All three paths now acquire the decode semaphore first and read the
blob under the permit, capping peak RAM at permits x image size:

- new ThumbnailService::get_thumbnail_from_blob defers the blob read
  into the moka init closure (cache and disk hits never touch the
  blob); both handlers use it and no longer pre-read.
- get_thumbnail_from_bytes (direct-bytes variant) now also takes a
  permit before decoding; shared generate_and_persist core keeps the
  two entrypoints duplicate-free.
- generate_all_sizes_background_from_blob (renamed from _from_bytes)
  checks blob existence and disk-dedup state, then acquires the
  permit, then reads; the redundant pre-read spawn wrapper in
  ThumbnailRefreshHook is gone.

https://claude.ai/code/session_01QxwJDHqQhbMkHK333QtMme
This commit is contained in:
Claude
2026-06-10 13:36:29 +00:00
parent 4200209d4a
commit b0f83cfa34
3 changed files with 153 additions and 90 deletions
+6 -12
View File
@@ -442,19 +442,13 @@ impl FileHandler {
.into_response();
}
let original_bytes = match state.core.dedup_service.read_blob_bytes(&blob_hash).await {
Ok(bytes) => bytes,
Err(err) => {
return AppError::internal_error(format!(
"Failed to load source image for thumbnail generation: {}",
err
))
.into_response();
}
};
match thumbnail_service
.get_thumbnail_from_bytes(&id, &blob_hash, thumb_size.into(), original_bytes)
.get_thumbnail_from_blob(
&id,
&blob_hash,
thumb_size.into(),
state.core.dedup_service.clone(),
)
.await
{
Ok(data) => Response::builder()