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>
This commit is contained in:
@@ -24,6 +24,16 @@ pub struct BlobMetadataDto {
|
||||
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 {
|
||||
@@ -83,6 +93,17 @@ 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>;
|
||||
|
||||
|
||||
@@ -756,6 +756,15 @@ impl DedupPort for StubDedupPort {
|
||||
false
|
||||
}
|
||||
|
||||
async fn find_derived_blob(
|
||||
&self,
|
||||
_source_hash: &str,
|
||||
_kind: &str,
|
||||
_variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, _hash: &str) -> Option<BlobMetadataDto> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -625,6 +625,33 @@ impl DedupService {
|
||||
Ok(derived_hash)
|
||||
}
|
||||
|
||||
/// Look up a derived artifact by its source content. Read counterpart of
|
||||
/// [`Self::store_derived_blob`].
|
||||
pub async fn find_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT blob_hash, content_type FROM storage.content_derived_blobs
|
||||
WHERE source_hash = $1 AND kind = $2 AND variant = $3",
|
||||
)
|
||||
.bind(source_hash)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(blob_hash, content_type)| {
|
||||
crate::application::ports::dedup_ports::DerivedBlobRef {
|
||||
blob_hash,
|
||||
content_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The registry backing the reap predicate.
|
||||
///
|
||||
/// Exposed so `blobs_consistency` recomputes refcounts from the *same*
|
||||
@@ -3295,6 +3322,15 @@ impl DedupPort for DedupService {
|
||||
self.blob_exists(hash).await
|
||||
}
|
||||
|
||||
async fn find_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
self.find_derived_blob(source_hash, kind, variant).await
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto> {
|
||||
self.get_blob_metadata(hash).await
|
||||
}
|
||||
|
||||
@@ -478,6 +478,11 @@ impl ThumbnailService {
|
||||
blob_hash: Option<&str>,
|
||||
size: ThumbnailSize,
|
||||
format: ThumbnailFormat,
|
||||
// Concrete, and optional: `ThumbnailPort` is never used as a trait
|
||||
// object (checked), and `DedupPort` uses native `async fn` so it is
|
||||
// not dyn-compatible anyway. `None` means sidecar-only — exactly
|
||||
// today's behaviour, which is what the port impl wants.
|
||||
dedup: Option<&DedupService>,
|
||||
) -> Option<Bytes> {
|
||||
// 1. Check in-memory cache
|
||||
let cache_key = ThumbnailCacheKey {
|
||||
@@ -520,10 +525,42 @@ impl ThumbnailService {
|
||||
let bytes = Bytes::from(data);
|
||||
// Populate in-memory cache for next hit
|
||||
self.cache.insert(cache_key, bytes.clone()).await;
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
return Some(bytes);
|
||||
}
|
||||
|
||||
// 4. Tier-3 derived blob. Deliberately LAST while the sidecar still
|
||||
// exists: for every thumbnail already on disk this branch is never
|
||||
// reached, so the DB stays off the hot path and a fault here cannot
|
||||
// break a working gallery. It answers only what disk cannot — another
|
||||
// instance's render, or a box whose sidecar was never populated.
|
||||
//
|
||||
// The order flips (derived blob first, sidecar as fallback) in the
|
||||
// release that removes the sidecar; see docs/plan/derived-blobs.md.
|
||||
let dedup = dedup?;
|
||||
let derived = dedup
|
||||
.find_derived_blob(hash, "thumbnail", size.dir_name())
|
||||
.await?;
|
||||
use futures::StreamExt;
|
||||
let mut stream = dedup.read_blob_stream(&derived.blob_hash).await.ok()?;
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(part) => buf.extend_from_slice(&part),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
"derived thumbnail read failed for {} {:?}",
|
||||
file_id,
|
||||
size,
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bytes = Bytes::from(buf);
|
||||
self.cache.insert(cache_key, bytes.clone()).await;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Store an externally-generated thumbnail (e.g. client-side video frame).
|
||||
@@ -1605,7 +1642,10 @@ impl ThumbnailPort for ThumbnailService {
|
||||
blob_hash: Option<&str>,
|
||||
size: PortThumbnailSize,
|
||||
) -> Option<Bytes> {
|
||||
self.get_cached_thumbnail(file_id, blob_hash, size.into(), ThumbnailFormat::Webp)
|
||||
// `None` — the abstract port has no DedupService handle, so it stays
|
||||
// sidecar-only. Callers wanting the tier-3 fallback use the concrete
|
||||
// method, which both handlers already do.
|
||||
self.get_cached_thumbnail(file_id, blob_hash, size.into(), ThumbnailFormat::Webp, None)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -484,7 +484,13 @@ impl FileHandler {
|
||||
// Try moka (RAM) → disk before touching the database.
|
||||
// If the thumbnail exists it was authorized at creation time.
|
||||
if let Some(data) = thumbnail_service
|
||||
.get_cached_thumbnail(&id, None, thumb_size.into(), format)
|
||||
.get_cached_thumbnail(
|
||||
&id,
|
||||
None,
|
||||
thumb_size.into(),
|
||||
format,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Response::builder()
|
||||
@@ -541,7 +547,13 @@ impl FileHandler {
|
||||
}
|
||||
};
|
||||
if let Some(data) = thumbnail_service
|
||||
.get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into(), format)
|
||||
.get_cached_thumbnail(
|
||||
&id,
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
format,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Response::builder()
|
||||
@@ -572,6 +584,7 @@ impl FileHandler {
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
ThumbnailFormat::Webp,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -204,6 +204,7 @@ pub async fn handle_preview(
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
ThumbnailFormat::Jpeg,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user