fix: prevent blob storage leak on folder deletion

- Add PG trigger trg_files_decrement_blob_ref (AFTER DELETE ON storage.files)
  that auto-decrements storage.blobs.ref_count for every deleted file row.
  Covers all paths: explicit DELETE, ON DELETE CASCADE, trash emptying.

- Remove manual remove_reference() call from delete_file() in
  file_blob_write_repository — trigger is now the single source of truth.

- Fix double-decrement bug in FileManagementService::delete_with_cleanup:
  was decrementing ref_count on trash (soft-delete) when the file row still
  existed, causing premature blob GC and potential data corruption on restore.

- Remove dead fields (file_read, dedup_service) from FileManagementService
  and simplify constructors — ref_count fully handled by PG trigger.
This commit is contained in:
Diocrafts
2026-02-22 22:07:46 +01:00
parent 35cfbba335
commit 2dd3dc0b54
5 changed files with 867 additions and 91 deletions
@@ -449,19 +449,18 @@ impl FileWritePort for FileBlobWriteRepository {
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
// Atomic DELETE RETURNING — one round-trip instead of SELECT + DELETE
let hash = sqlx::query_scalar::<_, String>(
"DELETE FROM storage.files WHERE id = $1::uuid RETURNING blob_hash",
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
// The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements storage.blobs.ref_count for the deleted row's blob_hash.
// Disk cleanup of orphaned blobs (ref_count = 0) is handled by
// garbage_collect().
let result = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?;
// Decrement blob reference (best-effort after successful DELETE)
if let Err(e) = self.dedup.remove_reference(&hash).await {
tracing::warn!("Failed to decrement blob ref for {}: {}", &hash[..12], e);
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", id));
}
Ok(())