bugfix/thumbnails on update

Bug 1 & 2 (webdav_handler.rs handle_put() update branch):
  - After a successful file update via WebDAV PUT, if the content type is a supported image:
    a. delete_thumbnails(file_id) — evicts the stale moka cache entry
    b. Spawns a background task to read the new blob bytes and call generate_all_sizes_background_from_bytes

  Bug 3 & 4 (dedup_service.rs):
  - Added thumbnail_service: Option<Arc<ThumbnailService>> field with a with_thumbnail_service() builder
  - In remove_legacy_reference(): calls delete_blob_thumbnails(hash) when ref_count hits 0
  - In remove_manifest_reference(): calls delete_blob_thumbnails(file_hash) when manifest's last ref is dropped
  - Wired in di.rs — the thumbnail service is created before dedup service so the ordering works cleanly
This commit is contained in:
Edouard Vanbelle
2026-04-27 20:41:19 +02:00
parent f62562d585
commit 9f57776ec9
6 changed files with 105 additions and 2 deletions
+44 -1
View File
@@ -263,7 +263,8 @@ impl AppServiceFactory {
blob_backend,
db_pool.clone(),
maintenance_pool.clone(),
),
)
.with_thumbnail_service(thumbnail_service.clone()),
);
dedup_service.initialize().await?;
@@ -981,6 +982,48 @@ pub struct CoreServices {
pub config: AppConfig,
}
impl CoreServices {
/// Invalidate a file's moka thumbnail cache and kick off background regeneration.
///
/// Call this after any write that swaps the blob for an existing file.
/// Safe to call for new files too (no-op on empty cache).
/// Skips everything if the MIME type is not a supported image.
pub async fn refresh_thumbnails_after_update(
&self,
file_id: String,
blob_hash: String,
content_type: &str,
) {
if !ThumbnailService::is_supported_image(content_type) {
return;
}
if let Err(e) = self.thumbnail_service.delete_thumbnails(&file_id).await {
tracing::warn!(
"Failed to invalidate thumbnail cache for {}: {}",
file_id,
e
);
}
let ts = self.thumbnail_service.clone();
let ds = self.dedup_service.clone();
let hash = blob_hash.clone();
tokio::spawn(async move {
match ds.read_blob_bytes(&hash).await {
Ok(bytes) => {
ts.generate_all_sizes_background_from_bytes(file_id, hash, bytes);
}
Err(e) => {
tracing::warn!(
"Failed to read blob for thumbnail regeneration {}: {}",
file_id,
e
);
}
}
});
}
}
/// Container for repository services
#[derive(Clone)]
pub struct RepositoryServices {
@@ -49,6 +49,7 @@ use crate::application::ports::dedup_ports::{
BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
// ── CDC Constants ────────────────────────────────────────────────────────────
@@ -83,6 +84,9 @@ pub struct DedupService {
/// Isolated maintenance pool for long-running operations
/// (verify_integrity, garbage_collect) that must never starve the primary.
maintenance_pool: Arc<PgPool>,
/// Optional thumbnail service — when set, blob-hash thumbnails are deleted
/// from disk whenever a blob's ref_count reaches zero.
thumbnail_service: Option<Arc<ThumbnailService>>,
}
impl DedupService {
@@ -100,9 +104,17 @@ impl DedupService {
backend,
pool,
maintenance_pool,
thumbnail_service: None,
}
}
/// Attach a thumbnail service so that disk thumbnails are cleaned up when
/// a blob's ref_count drops to zero.
pub fn with_thumbnail_service(mut self, svc: Arc<ThumbnailService>) -> Self {
self.thumbnail_service = Some(svc);
self
}
/// Creates a stub instance for testing — never hits PG or the filesystem.
#[cfg(any(test, feature = "integration_tests"))]
pub fn new_stub() -> Self {
@@ -117,6 +129,7 @@ impl DedupService {
backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))),
pool: stub_pool.clone(),
maintenance_pool: stub_pool,
thumbnail_service: None,
}
}
@@ -772,6 +785,11 @@ impl DedupService {
}
}
// Bug 4 fix: delete disk thumbnails keyed by file_hash (last reference gone)
if let Some(ts) = &self.thumbnail_service {
ts.delete_blob_thumbnails(file_hash).await;
}
tracing::info!(
"MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)",
&file_hash[..12],
@@ -849,6 +867,11 @@ impl DedupService {
tracing::warn!("Failed to delete blob file {}: {}", hash, e);
}
// Bug 3 fix: delete disk thumbnails keyed by hash (last reference gone)
if let Some(ts) = &self.thumbnail_service {
ts.delete_blob_thumbnails(hash).await;
}
tracing::info!("BLOB DELETED: {} (no more references)", &hash[..12]);
Ok(true)
} else {
@@ -972,6 +972,15 @@ async fn handle_put(
);
}
state
.core
.refresh_thumbnails_after_update(
file_dto.id.clone(),
file_dto.etag.clone(),
&content_type,
)
.await;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
+13 -1
View File
@@ -288,7 +288,19 @@ async fn put_file(
let _ = tokio::fs::remove_file(&temp_path).await;
match result {
Ok(_) => StatusCode::OK.into_response(),
Ok(file_dto) => {
state
.app_state
.core
.refresh_thumbnails_after_update(
file_dto.id.clone(),
file_dto.etag.clone(),
&content_type,
)
.await;
StatusCode::OK.into_response()
}
Err(e) => {
tracing::error!("WOPI PutFile failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -163,6 +163,12 @@ async fn handle_assemble(
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
state
.core
.refresh_thumbnails_after_update(dto.id.clone(), dto.etag.clone(), &content_type)
.await;
Some(dto.etag)
} else {
// For new files we still need to read the temp file since create_file takes &[u8].
@@ -566,6 +566,16 @@ async fn handle_put(
}
}
// Bug 1 & 2 fix: invalidate stale thumbnail and regenerate from new blob.
state
.core
.refresh_thumbnails_after_update(
updated.id.clone(),
updated.etag.clone(),
&content_type,
)
.await;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", updated.etag))