Files
Oxicloud/src/application/ports/dedup_ports.rs
T
Edouard Vanbelle 60b94e1183 feat(thumbnails): serve derived blobs when the sidecar cannot
Step 5, read path — Option 2 of the two shapes discussed: the derived
blob is consulted LAST, after the sidecar, not first.

Read order is now
  moka -> ext-{file_id}.jpg -> {blob_hash}.webp on disk -> derived blob

For every thumbnail already on disk the new branch is never reached, so
the database stays off the hot path and a fault in it cannot break a
working gallery. It answers only what disk cannot: a thumbnail rendered
by another instance, or a box whose sidecar was never populated. Legacy
content keeps serving from disk until `derived_import` migrates it.

That inverts the plan's stated order deliberately. Derived-blob-first is
right for the END state, because it is what lets the sidecar be deleted;
sidecar-first is right transitionally, because the risky reordering
should happen after the table has been seen serving real reads. The flip
belongs in the release that removes the sidecar, and the comment at the
branch says so.

The existing precedence is preserved and now documented: the file-keyed
client upload (ext-) is checked BEFORE the content-keyed server render.
That ordering is a security property, not a preference — content-keyed
artifacts are shared across every file with that content, so checking
the file-keyed one first is what keeps one user's uploaded preview from
ever being served for another user's identical file.

Shape notes:

* `find_derived_blob` lands on DedupPort/DedupService as the read
  counterpart of `store_derived_blob`, so ThumbnailService needs no pool
  field — and therefore ThumbnailService::new, DI and three tests are
  untouched.
* It carries `content_type`, which is what will retire the byte-sniffing
  in the handlers once reads are table-primary.
* The parameter is `Option<&DedupService>`, concrete rather than
  `&dyn DedupPort`: DedupPort uses native `async fn` and so is not
  dyn-compatible, and ThumbnailPort is never used as a trait object
  (checked) — both handlers hold the concrete Arc. `None` means
  sidecar-only, which is exactly today's behaviour and what the abstract
  port impl passes.

fmt, clippy --all-features --all-targets, 35 unit tests clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:41:04 +02:00

162 lines
5.5 KiB
Rust

//! Deduplication Port - Application layer abstraction for content-addressable storage.
//!
//! This module defines the port (trait) and DTOs for deduplication operations,
//! keeping the application and interface layers independent of the specific
//! content-addressable storage implementation.
use crate::common::errors::DomainError;
use bytes::Bytes;
use futures::Stream;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::pin::Pin;
/// Metadata of a stored blob in the dedup system.
#[derive(Debug, Clone, Serialize)]
pub struct BlobMetadataDto {
/// BLAKE3 hash of the content.
pub hash: String,
/// Size in bytes.
pub size: u64,
/// Number of references to this blob.
pub ref_count: u32,
/// Original content type (for serving).
pub content_type: Option<String>,
}
/// A stored server-derived artifact: which blob holds it, and what it is.
///
/// `content_type` is carried so the read path can set the response header
/// without byte-sniffing the payload, which is what it does today.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedBlobRef {
pub blob_hash: String,
pub content_type: String,
}
/// Result of a deduplication store operation.
#[derive(Debug, Clone)]
pub enum DedupResultDto {
/// New content was stored (first occurrence).
NewBlob { hash: String, size: u64 },
/// Content already existed; a reference was added instead.
ExistingBlob {
hash: String,
size: u64,
saved_bytes: u64,
},
}
impl DedupResultDto {
pub fn hash(&self) -> &str {
match self {
DedupResultDto::NewBlob { hash, .. } => hash,
DedupResultDto::ExistingBlob { hash, .. } => hash,
}
}
pub fn size(&self) -> u64 {
match self {
DedupResultDto::NewBlob { size, .. } => *size,
DedupResultDto::ExistingBlob { size, .. } => *size,
}
}
pub fn was_deduplicated(&self) -> bool {
matches!(self, DedupResultDto::ExistingBlob { .. })
}
}
/// Statistics for the deduplication service.
#[derive(Debug, Clone, Default, Serialize)]
pub struct DedupStatsDto {
/// Total number of unique blobs.
pub total_blobs: u64,
/// Total bytes stored (actual disk usage).
pub total_bytes_stored: u64,
/// Total bytes referenced (logical size).
pub total_bytes_referenced: u64,
/// Bytes saved through deduplication.
pub bytes_saved: u64,
/// Number of deduplication hits.
pub dedup_hits: u64,
/// Deduplication ratio (referenced / stored).
pub dedup_ratio: f64,
}
/// Port for content-addressable deduplication operations.
///
/// Implementations store files by their content hash, eliminating
/// duplicate storage automatically. Multiple file references can
/// point to the same physical blob.
pub trait DedupPort: Send + Sync + 'static {
/// Check if a blob with the given hash exists.
async fn blob_exists(&self, hash: &str) -> bool;
/// Look up a server-derived artifact by the content it was derived from.
///
/// The read counterpart of `store_derived_blob`. Returns `None` when no
/// such variant has been derived yet — the caller then renders it.
async fn find_derived_blob(
&self,
source_hash: &str,
kind: &str,
variant: &str,
) -> Option<DerivedBlobRef>;
/// Get metadata for a blob.
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto>;
/// Stream blob content in chunks (64 KB default) — constant memory usage.
async fn read_blob_stream(
&self,
hash: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>;
/// Stream a byte range of a blob — only reads the requested portion.
///
/// Uses seek + take so a 1 MB range on a 1 GB file only reads 1 MB from disk.
async fn read_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>;
/// Get the size of a blob without reading its content.
///
/// Used by HEAD requests to return Content-Length without loading the file.
async fn blob_size(&self, hash: &str) -> Result<u64, DomainError>;
/// Add a reference to a blob (increment ref_count).
async fn add_reference(&self, hash: &str) -> Result<(), DomainError>;
/// Remove a reference from a blob.
///
/// Returns `true` if the last reference was removed (the content is now
/// unreferenced). For CDC content the now-orphaned chunks are reclaimed
/// later by garbage collection rather than unlinked inline; legacy
/// whole-file blobs are still freed eagerly.
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
/// Calculate BLAKE3 hash of a file (streaming).
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
/// Get the physical filesystem path for a blob by its hash.
///
/// Returns the path where the blob is stored on disk.
/// Used by services that need direct filesystem access (e.g., thumbnail generation).
fn blob_path(&self, hash: &str) -> PathBuf;
/// Get deduplication statistics.
async fn get_stats(&self) -> DedupStatsDto;
/// Flush the index to persistent storage.
async fn flush(&self) -> Result<(), DomainError>;
/// Verify integrity of all stored blobs.
///
/// Returns a list of issues found (empty if everything is OK).
async fn verify_integrity(&self) -> Result<Vec<String>, DomainError>;
}