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:
+44
-1
@@ -263,7 +263,8 @@ impl AppServiceFactory {
|
|||||||
blob_backend,
|
blob_backend,
|
||||||
db_pool.clone(),
|
db_pool.clone(),
|
||||||
maintenance_pool.clone(),
|
maintenance_pool.clone(),
|
||||||
),
|
)
|
||||||
|
.with_thumbnail_service(thumbnail_service.clone()),
|
||||||
);
|
);
|
||||||
dedup_service.initialize().await?;
|
dedup_service.initialize().await?;
|
||||||
|
|
||||||
@@ -981,6 +982,48 @@ pub struct CoreServices {
|
|||||||
pub config: AppConfig,
|
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
|
/// Container for repository services
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RepositoryServices {
|
pub struct RepositoryServices {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ use crate::application::ports::dedup_ports::{
|
|||||||
BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto,
|
BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto,
|
||||||
};
|
};
|
||||||
use crate::domain::errors::{DomainError, ErrorKind};
|
use crate::domain::errors::{DomainError, ErrorKind};
|
||||||
|
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
|
||||||
|
|
||||||
// ── CDC Constants ────────────────────────────────────────────────────────────
|
// ── CDC Constants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -83,6 +84,9 @@ pub struct DedupService {
|
|||||||
/// Isolated maintenance pool for long-running operations
|
/// Isolated maintenance pool for long-running operations
|
||||||
/// (verify_integrity, garbage_collect) that must never starve the primary.
|
/// (verify_integrity, garbage_collect) that must never starve the primary.
|
||||||
maintenance_pool: Arc<PgPool>,
|
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 {
|
impl DedupService {
|
||||||
@@ -100,9 +104,17 @@ impl DedupService {
|
|||||||
backend,
|
backend,
|
||||||
pool,
|
pool,
|
||||||
maintenance_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.
|
/// Creates a stub instance for testing — never hits PG or the filesystem.
|
||||||
#[cfg(any(test, feature = "integration_tests"))]
|
#[cfg(any(test, feature = "integration_tests"))]
|
||||||
pub fn new_stub() -> Self {
|
pub fn new_stub() -> Self {
|
||||||
@@ -117,6 +129,7 @@ impl DedupService {
|
|||||||
backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))),
|
backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))),
|
||||||
pool: stub_pool.clone(),
|
pool: stub_pool.clone(),
|
||||||
maintenance_pool: stub_pool,
|
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!(
|
tracing::info!(
|
||||||
"MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)",
|
"MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)",
|
||||||
&file_hash[..12],
|
&file_hash[..12],
|
||||||
@@ -849,6 +867,11 @@ impl DedupService {
|
|||||||
tracing::warn!("Failed to delete blob file {}: {}", hash, e);
|
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]);
|
tracing::info!("BLOB DELETED: {} (no more references)", &hash[..12]);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} 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()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
|
|||||||
@@ -288,7 +288,19 @@ async fn put_file(
|
|||||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
|
||||||
match result {
|
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) => {
|
Err(e) => {
|
||||||
tracing::error!("WOPI PutFile failed: {}", e);
|
tracing::error!("WOPI PutFile failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ async fn handle_assemble(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
.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)
|
Some(dto.etag)
|
||||||
} else {
|
} else {
|
||||||
// For new files we still need to read the temp file since create_file takes &[u8].
|
// 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()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.header(header::ETAG, format!("\"{}\"", updated.etag))
|
.header(header::ETAG, format!("\"{}\"", updated.etag))
|
||||||
|
|||||||
Reference in New Issue
Block a user