2026-04-14 23:17:39 +02:00
//! Content-Addressable Storage with CDC Deduplication (PostgreSQL-backed)
2026-02-14 01:29:34 +01:00
//!
2026-04-14 23:17:39 +02:00
//! Implements sub-file deduplication using FastCDC (content-defined chunking).
//! Files are split into variable-size chunks (64 KB – 1 MB, avg 256 KB)
//! using the FastCDC 2020 algorithm. Each chunk is BLAKE3-hashed and stored
//! independently in the blob backend. A *manifest* in PostgreSQL maps the
//! whole-file hash to the ordered list of chunk hashes that compose it.
2026-02-14 01:29:34 +01:00
//!
//! Architecture:
//! ```text
2026-04-14 23:17:39 +02:00
//! ┌─────────────────┐ ┌─────────────────────┐ ┌─────────────┐
//! │ storage.files │────▶│ chunk_manifests │────▶│ storage.blobs│──▶ Blob Store
//! │ (references) │ │ (file→[chunk_hashes])│ │ (chunks) │
//! └─────────────────┘ └─────────────────────┘ └─────────────┘
2026-02-14 01:29:34 +01:00
//! ```
//!
2026-04-14 23:17:39 +02:00
//! **Backward compatibility**: files uploaded before CDC (legacy whole-file
//! blobs in `storage.blobs`) are served transparently — when no manifest
//! row exists for a hash, the service falls back to direct blob reads.
2026-02-25 23:31:51 +01:00
//!
2026-06-11 13:06:33 +00:00
//! **Single-pass streaming ingest** (store_from_stream):
//! 1. FastCDC boundaries, per-chunk BLAKE3 and the whole-file BLAKE3 are
//! all computed WHILE the bytes arrive — no spool file, no mmap
//! re-read. Peak RAM stays bounded (current chunk + one small batch).
//! 2. Per batch of distinct chunks, ONE `UPDATE … RETURNING` bumps
//! ref_count on already-known chunks (pinning them against concurrent
//! reclaim for the rest of the upload) and atomically classifies the
//! rest as new — no check-then-bump TOCTOU window.
//! 3. Only *new* chunks are written to the blob backend (unsynced,
//! bounded concurrency). Bytes the store already knows never touch
//! disk — a full dedup hit performs zero content writes.
//! 4. At end of stream ONE batched fsync sweep makes the new chunks
//! durable, then ONE batched INSERT registers them — durability
//! before visibility.
//! 5. Single manifest INSERT (~few ms). An identical concurrent upload
//! is resolved via ON CONFLICT: the loser releases its chunk
//! references and turns into a dedup hit.
//! 6. PG connections are never held during disk I/O.
2026-02-14 19:30:49 +01:00
//!
2026-02-14 01:29:34 +01:00
//! Benefits:
2026-06-11 13:06:33 +00:00
//! - Each uploaded byte hits the disk at most ONCE (dedup hits: zero)
2026-04-14 23:17:39 +02:00
//! - Sub-file dedup: edited files share unchanged chunks
2026-02-14 19:30:49 +01:00
//! - ACID durability — crash-safe, zero orphaned index entries
2026-04-14 23:17:39 +02:00
//! - 60-80% storage reduction for versioned / edited files
2026-02-14 01:29:34 +01:00
use bytes ::Bytes ;
2026-02-23 23:43:59 +01:00
use futures ::stream ::{ self , StreamExt };
2026-02-24 10:45:38 +01:00
use futures ::{ Stream , TryStreamExt };
2026-03-01 21:47:39 +01:00
2026-02-14 19:30:49 +01:00
use sqlx ::PgPool ;
2026-07-22 02:06:04 +02:00
use std ::collections ::{ HashMap , HashSet };
2026-02-14 01:29:34 +01:00
use std ::path ::{ Path , PathBuf };
2026-02-15 17:53:25 +01:00
use std ::pin ::Pin ;
2026-02-14 01:29:34 +01:00
use std ::sync ::Arc ;
2026-06-11 13:06:33 +00:00
use tokio_util ::io ::StreamReader ;
2026-02-14 01:29:34 +01:00
2026-05-22 13:10:47 +02:00
use crate ::application ::ports ::blob_lifecycle ::BlobLifecycleHook ;
2026-08-23 15:52:01 +02:00
use crate ::application ::ports ::blob_reference_ports ::{ BlobReferenceRegistry , RefLevel };
2026-05-13 13:27:33 +02:00
use crate ::application ::ports ::blob_storage_ports ::BlobStorageBackend ;
2026-02-14 01:29:34 +01:00
use crate ::application ::ports ::dedup_ports ::{
BlobMetadataDto , DedupPort , DedupResultDto , DedupStatsDto ,
};
2026-05-22 13:10:47 +02:00
use crate ::application ::services ::blob_lifecycle_service ::BlobLifecycleService ;
2026-02-14 01:29:34 +01:00
use crate ::domain ::errors ::{ DomainError , ErrorKind };
2026-04-14 23:17:39 +02:00
// ── CDC Constants ────────────────────────────────────────────────────────────
/// Minimum CDC chunk size (64 KB).
2026-06-11 14:34:02 +00:00
pub const CDC_MIN_CHUNK : usize = 65_536 ;
2026-04-14 23:17:39 +02:00
/// Average CDC chunk size (256 KB).
2026-06-11 14:34:02 +00:00
pub const CDC_AVG_CHUNK : usize = 262_144 ;
2026-04-14 23:17:39 +02:00
/// Maximum CDC chunk size (1 MB).
2026-06-11 14:34:02 +00:00
pub const CDC_MAX_CHUNK : usize = 1_048_576 ;
2026-04-14 23:17:39 +02:00
// ── CDC helper types ─────────────────────────────────────────────────────────
2026-06-11 13:06:33 +00:00
/// Everything a streaming chunk ingest learned about its byte stream.
///
/// Produced by [`DedupService::ingest_chunks_from_stream`]. On success the
/// ingest session holds exactly ONE `storage.blobs.ref_count` reference per
/// *distinct* chunk hash; the caller must either attach those references to
/// a manifest or hand them back via `release_chunk_refs`.
struct ChunkIngestOutcome {
/// BLAKE3 of the complete byte stream (the future manifest key).
file_hash : String ,
/// Total bytes consumed from the stream.
total_size : u64 ,
/// Per-occurrence chunk hashes, in file order (the manifest layout).
chunk_hashes : Vec < String > ,
/// Per-occurrence chunk sizes, in file order.
chunk_sizes : Vec < u64 > ,
/// How many distinct chunks were actually written to the backend.
newly_written : usize ,
}
impl ChunkIngestOutcome {
/// Distinct chunk hashes — the set this ingest holds one reference on each.
fn distinct_hashes ( & self ) -> Vec < String > {
let mut seen = HashSet ::new ();
self . chunk_hashes
. iter ()
. filter ( | h | seen . insert ( h . as_str ()))
. cloned ()
. collect ()
}
}
/// Compensation guard for an in-flight ingest session.
///
/// Tracks the two side effects a session accumulates before its chunks are
/// fully registered: ref_count pins taken on pre-existing chunks and freshly
/// written (still unregistered) chunk files. If the session future is dropped
/// mid-stream — a client disconnect aborts the whole handler future — the
/// guard spawns a rollback so pinned chunks don't leak references forever and
/// written files become GC-collectible rows instead of invisible orphans.
2026-07-16 16:50:07 +00:00
/// Whether the ingest loop overlaps batch settling with source reading
/// (default on). `OXICLOUD_INGEST_OVERLAP=0` restores the old inline
/// behaviour — kept as a bench/ops escape hatch.
fn ingest_overlap_enabled () -> bool {
static ENABLED : std ::sync ::OnceLock < bool > = std ::sync ::OnceLock ::new ();
* ENABLED . get_or_init ( || {
std ::env ::var ( "OXICLOUD_INGEST_OVERLAP" ). map_or ( true , | v | v != "0" && v != "false" )
})
}
/// Compensation ledger of one ingest session. Shared (`Arc<tokio::Mutex>`)
/// between the ingest loop and the overlapped batch-settle task: the settler
/// holds the lock for the whole batch and records progressively, so a
/// rollback (explicit or Drop-spawned) that acquires the lock is guaranteed
/// to observe every pin/write the in-flight settle made.
#[derive(Default)]
struct IngestState {
2026-06-11 13:06:33 +00:00
/// Pre-existing chunks whose ref_count this session bumped (distinct).
pinned : Vec < String > ,
/// Chunks written to the backend but not yet registered: (hash, size).
written : Vec < ( String , i64 ) > ,
2026-07-16 16:50:07 +00:00
}
struct IngestGuard {
pool : Arc < PgPool > ,
backend : Arc < dyn BlobStorageBackend > ,
state : Arc < tokio ::sync ::Mutex < IngestState >> ,
2026-06-11 13:06:33 +00:00
armed : bool ,
}
impl IngestGuard {
fn new ( pool : Arc < PgPool > , backend : Arc < dyn BlobStorageBackend > ) -> Self {
Self {
pool ,
backend ,
2026-07-16 16:50:07 +00:00
state : Arc ::new ( tokio ::sync ::Mutex ::new ( IngestState ::default ())),
2026-06-11 13:06:33 +00:00
armed : true ,
}
}
/// The session's chunks are fully registered — references now belong to
/// the caller, nothing to compensate.
fn disarm ( mut self ) {
self . armed = false ;
}
/// Deterministic rollback for handled errors (awaited inline, unlike the
/// spawned Drop path).
async fn rollback ( mut self ) {
self . armed = false ;
2026-07-16 16:50:07 +00:00
// Lock acquisition serializes after any in-flight batch settle, so
// its pins/writes are visible here.
let ( pinned , written ) = {
let mut st = self . state . lock (). await ;
(
std ::mem ::take ( & mut st . pinned ),
std ::mem ::take ( & mut st . written ),
)
};
2026-06-11 13:06:33 +00:00
Self ::run_rollback ( self . pool . clone (), self . backend . clone (), pinned , written ). await ;
}
/// Release pins and surface written-but-unregistered chunk files to GC.
///
/// Best-effort: every step logs instead of failing — the worst outcome of
/// a failed rollback is a bounded ref_count over-count (storage leak),
/// never data loss.
async fn run_rollback (
pool : Arc < PgPool > ,
backend : Arc < dyn BlobStorageBackend > ,
pinned : Vec < String > ,
written : Vec < ( String , i64 ) > ,
) {
if ! pinned . is_empty ()
&& let Err ( e ) = sqlx ::query (
2026-06-19 09:37:51 +00:00
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
2026-06-11 13:06:33 +00:00
WHERE hash = ANY($1)" ,
)
. bind ( & pinned )
. execute ( pool . as_ref ())
. await
{
tracing ::warn! (
"Ingest rollback: failed to release {} chunk pins: {e}" ,
pinned . len ()
);
}
if written . is_empty () {
return ;
}
// Durability first, then visibility at ref_count 0 so the existing GC
// sweep can reclaim the bytes — a backend file with no PG row would be
// invisible to it. ON CONFLICT DO NOTHING keeps a concurrent
// uploader's row (and its references) intact.
2026-07-20 15:25:42 +00:00
// `written` is owned and dead after this rollback — unzip it (moving each
// 64-byte hash String out) instead of cloning every hash purely to
// reshape for `sync_blobs(&[String])` + the UNNEST bind.
// (benches/ROUND23.md §U1)
let ( hashes , sizes ) : ( Vec < String > , Vec < i64 > ) = written . into_iter (). unzip ();
2026-06-11 13:06:33 +00:00
if let Err ( e ) = backend . sync_blobs ( & hashes ). await {
tracing ::warn! (
"Ingest rollback: sync of {} chunks failed: {e}" ,
hashes . len ()
);
}
if let Err ( e ) = sqlx ::query (
2026-06-19 09:37:51 +00:00
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
2026-06-11 13:06:33 +00:00
ON CONFLICT (hash) DO NOTHING" ,
)
. bind ( & hashes )
. bind ( & sizes )
. execute ( pool . as_ref ())
. await
{
tracing ::warn! (
"Ingest rollback: failed to register {} orphan chunks for GC: {e}" ,
hashes . len ()
);
}
}
}
impl Drop for IngestGuard {
fn drop ( & mut self ) {
2026-07-16 16:50:07 +00:00
if ! self . armed {
2026-06-11 13:06:33 +00:00
return ;
}
2026-07-16 16:50:07 +00:00
// The rollback task locks the shared state first, so it naturally
// waits out an in-flight batch settle and observes its recordings.
let state = self . state . clone ();
2026-06-11 13:06:33 +00:00
match tokio ::runtime ::Handle ::try_current () {
Ok ( handle ) => {
let pool = self . pool . clone ();
let backend = self . backend . clone ();
handle . spawn ( async move {
2026-07-16 16:50:07 +00:00
let ( pinned , written ) = {
let mut st = state . lock (). await ;
(
std ::mem ::take ( & mut st . pinned ),
std ::mem ::take ( & mut st . written ),
)
};
if pinned . is_empty () && written . is_empty () {
return ;
}
2026-06-11 13:06:33 +00:00
Self ::run_rollback ( pool , backend , pinned , written ). await ;
});
}
Err ( _ ) => tracing ::warn! (
2026-07-16 16:50:07 +00:00
"Ingest guard dropped outside a runtime: any pins / written chunks \
2026-06-11 13:06:33 +00:00
stay leaked until the next GC sweep" ,
),
}
}
2026-04-14 23:17:39 +02:00
}
/// Content-Addressable Storage Service with CDC (PostgreSQL-backed)
2026-04-14 21:33:38 +02:00
///
2026-04-14 23:17:39 +02:00
/// Splits files into variable-size chunks via FastCDC, stores each chunk
/// in the [`BlobStorageBackend`], and maintains a manifest in PostgreSQL
/// mapping file_hash → \[chunk_hashes\]. BLAKE3 hashing, ref-counting
/// and the PostgreSQL dedup index all live here.
2026-07-16 14:20:20 +00:00
/// Immutable chunk map of one CDC blob (`storage.chunk_manifests` row,
/// minus the mutable `ref_count`). Content-addressed: for a given
/// `file_hash` the chunk list and total size never change, which is what
/// makes [`DedupService::manifest_cached`] safe.
pub struct ChunkManifest {
pub chunk_hashes : Vec < String > ,
pub chunk_sizes : Vec < i64 > ,
pub total_size : i64 ,
}
2026-07-22 02:06:04 +02:00
type IntegrityManifest = ( String , Vec < String > , Vec < i64 > , i64 );
const INTEGRITY_SERIAL_FAST_PATH_OCCURRENCES : usize = 4 ;
struct IntegrityBlobSizes < 'a > {
/// Sorted borrowed keys make the scratch table compact and avoid cloning
/// 64-byte content hashes. Windows contain at most 256 occurrences, so an
/// O(log N) lookup is bounded to eight string comparisons.
hashes : Vec <& 'a str > ,
sizes : Vec < Option < u64 >> ,
}
impl < 'a > IntegrityBlobSizes < 'a > {
fn new ( mut hashes : Vec <& 'a str > ) -> Self {
hashes . sort_unstable ();
hashes . dedup ();
let sizes = vec! [ None ; hashes . len ()];
Self { hashes , sizes }
}
#[inline]
fn get ( & self , hash : & str ) -> Option < u64 > {
self . hashes
. binary_search ( & hash )
. ok ()
. and_then ( | index | self . sizes [ index ])
}
}
#[inline]
fn integrity_uses_serial_fast_path ( manifests : & [ IntegrityManifest ]) -> bool {
if manifests . len () == 1 {
let ( _ , hashes , sizes , _ ) = & manifests [ 0 ];
return hashes . len () != sizes . len ()
|| hashes . len () <= INTEGRITY_SERIAL_FAST_PATH_OCCURRENCES ;
}
let mut occurrences = 0 usize ;
for ( _ , hashes , sizes , _ ) in manifests {
if hashes . len () == sizes . len () {
occurrences = occurrences . saturating_add ( hashes . len ());
if occurrences > INTEGRITY_SERIAL_FAST_PATH_OCCURRENCES {
return false ;
}
}
}
true
}
/// Unique backend keys referenced by structurally valid manifests.
///
/// A malformed row is skipped wholesale by the historical integrity check;
/// including its hashes here would add backend I/O and could produce messages
/// that the serial implementation never emitted.
fn integrity_chunk_sizes ( manifests : & [ IntegrityManifest ]) -> IntegrityBlobSizes < '_ > {
let mut hashes = Vec ::new ();
for ( _ , chunk_hashes , chunk_sizes , _ ) in manifests {
if chunk_hashes . len () == chunk_sizes . len () {
hashes . extend ( chunk_hashes . iter (). map ( String ::as_str ));
}
}
IntegrityBlobSizes ::new ( hashes )
}
/// Replay manifest validation in database/occurrence order from one backend
/// result per distinct hash. Keeping formatting here preserves the exact
/// issue text (including one message for every repeated occurrence).
fn integrity_manifest_issues (
manifests : & [ IntegrityManifest ],
blob_sizes : & IntegrityBlobSizes < '_ > ,
) -> Vec < String > {
let mut issues = Vec ::new ();
for ( file_hash , chunk_hashes , chunk_sizes , total_size ) in manifests {
let label = & file_hash [ .. file_hash . len (). min ( 12 )];
if chunk_hashes . len () != chunk_sizes . len () {
issues . push ( format! (
"Manifest {label} : chunk_hashes/chunk_sizes length mismatch"
));
continue ;
}
let sum : i64 = chunk_sizes . iter (). sum ();
if sum != * total_size {
issues . push ( format! (
"Manifest {label} : total_size {total_size} != sum of chunk_sizes {sum} "
));
}
for ( i , chunk_hash ) in chunk_hashes . iter (). enumerate () {
let chunk_label = & chunk_hash [ .. chunk_hash . len (). min ( 12 )];
match blob_sizes . get ( chunk_hash ) {
Some ( actual_size ) => {
if actual_size != chunk_sizes [ i ] as u64 {
issues . push ( format! (
"Manifest {label} chunk {chunk_label} : size mismatch \
(expected {} , actual {actual_size} )" ,
chunk_sizes [ i ]
));
}
}
None => issues . push ( format! (
"Manifest {label} chunk {chunk_label} : missing in backend"
)),
}
}
}
issues
}
async fn populate_integrity_blob_sizes < 'a > (
backend : Arc < dyn BlobStorageBackend > ,
blob_sizes : IntegrityBlobSizes < 'a > ,
concurrency : usize ,
) -> IntegrityBlobSizes < 'a > {
let concurrency = concurrency . max ( 1 );
let IntegrityBlobSizes { hashes , mut sizes } = blob_sizes ;
let mut pending = futures ::stream ::FuturesUnordered ::new ();
let mut next = 0 usize ;
while next < hashes . len () || ! pending . is_empty () {
while next < hashes . len () && pending . len () < concurrency {
let index = next ;
let hash = hashes [ index ];
let backend = backend . clone ();
pending . push ( async move {
let size = backend . blob_size ( hash ). await . ok ();
( index , size )
});
next += 1 ;
}
if let Some (( index , size )) = pending . next (). await {
sizes [ index ] = size ;
}
}
IntegrityBlobSizes { hashes , sizes }
}
2026-08-23 15:52:01 +02:00
/// Build the manifest reap statement from the registered reference sources.
///
2026-09-04 12:30:12 +02:00
/// **A manifest is collectible when, and only when, no registered source
/// references it.** The reference registry is the sole authority; `ref_count`
/// does not appear in this predicate at all.
2026-08-23 15:52:01 +02:00
///
2026-09-04 12:30:12 +02:00
/// # Why `ref_count` was removed from it
///
/// This used to read `ref_count <= 0 OR <unreferenced>`. Each arm had a
/// purpose — the single-file delete path decrements the counter via
/// `cleanup_if_orphaned`, while bulk paths (user cascade, `empty_trash`) only
/// fire the `storage.blobs` trigger and leave the counter untouched — so the
/// disjunction looked like belt and braces.
///
/// It was the opposite. With `OR`, **either signal alone deletes**, so a
/// counter that under-reports does not merely report a wrong number: it makes
/// live content collectible, and the registry that knows better is never
/// consulted because the first arm already matched. That is not hypothetical.
/// `storage.copy_folder_tree` used to take references with
/// `UPDATE storage.blobs … WHERE hash = blob_hash`, which matches nothing for
/// a CDC file — whose `blob_hash` names a manifest, not a chunk — so it took
/// no reference at all. Copy a folder, delete the original, and the copy's
/// bytes were reaped.
///
/// Dropping the counter arm loses no coverage, because the single-file path
/// deletes the `storage.files` row too, which makes the row unreferenced
/// anyway. And it costs no performance: under `OR`, Postgres had to evaluate
/// the `EXISTS` union for every row whose `ref_count` was above zero — which
/// on a healthy install is nearly all of them — so the expensive predicate was
/// already running unconditionally.
///
/// What it does change: a counter stuck *high* with no referrers left is no
/// longer reaped here. That is the bulk-delete residue, and it now belongs to
/// the manifest-level refcount recompute (`docs/plan/derived-blobs.md`,
/// coverage matrix row 7) — a counter being wrong is a job for the thing that
/// reconciles counters, not for the thing that deletes data.
///
/// The predicate is registry-driven rather than naming `storage.files`
/// directly, so a new referring table — thumbnails via
/// `storage.content_derived_blobs`, previews via
/// `storage.file_attached_blobs` — is covered by registering its source.
/// Hardcoded, each new table would have had its manifests reaped on the next
/// sweep despite a correct `ref_count`.
///
/// Pinned by `gc_reference_authority_integration_tests`.
2026-08-23 15:52:01 +02:00
///
/// # Panics
///
/// If no source contributes at [`RefLevel::Manifest`]. That is a wiring bug,
/// and it must be loud: with no source, "nothing references it" is vacuously
/// true for every row and this statement would delete every manifest in the
/// database. `DedupService::new` always registers `FilesReferenceSource`, so
/// the only way to reach this is to pass a deliberately empty registry.
2026-09-04 21:43:49 +02:00
/// Build the chunk/blob reap statement (GC phase 2) from the registered
/// reference sources.
///
/// Unlike [`manifest_reap_sql`], the registry predicate here is **added to**
/// the hardcoded guards rather than replacing them. That asymmetry is
/// deliberate and the reason this was not a mechanical swap.
///
/// `no_reference_predicate` is built from fragments designed for *counting*,
/// and `FilesReferenceSource`'s chunk-level fragment deliberately excludes
/// files whose `blob_hash` has a manifest — otherwise a single-chunk blob,
/// where the file hash and its lone chunk hash are the same BLAKE3, would be
/// counted at both levels. Correct for a recompute; too narrow for a reap
/// guard. A `storage.blobs` row keyed by a MULTI-chunk file's hash — which
/// exists transiently while `rechunk` migrates a legacy blob, and is not a
/// member of its own manifest's `chunk_hashes` — would satisfy the registry's
/// "unreferenced" test while a live `storage.files` row still points at it.
/// Swapping the guards out would have reaped it mid-migration.
///
/// So the statement keeps `NOT EXISTS (manifest lists it as a chunk)` and
/// `NOT EXISTS (any file points at it)`, and ANDs the registry predicate on
/// top. Adding a conjunct can only ever spare more rows, never reap more, so
/// this cannot regress; what it buys is that a future source contributing at
/// [`RefLevel::Chunk`] is honoured automatically instead of being silently
/// missed — the same failure that made Phase 1's hardcoded cross-check
/// dangerous.
///
/// Today the registry adds nothing operationally:
/// `content_derived_blobs` and `file_attached_blobs` both return `None` at
/// `RefLevel::Chunk`, so its union is exactly manifests + legacy files. The
/// point is what happens when that stops being true.
///
/// `$1` is the batch limit, `$2` the grace window in seconds.
///
/// # Panics
///
/// If no source contributes at [`RefLevel::Chunk`]. Same reasoning as
/// [`manifest_reap_sql`]: a missing predicate must be loud rather than
/// silently degrading to "nothing references anything".
fn blob_reap_sql ( registry : & BlobReferenceRegistry ) -> String {
let unreferenced = registry
. no_reference_predicate ( RefLevel ::Chunk , "b.hash" )
. expect (
"no chunk-level blob reference source registered: the reap \
predicate would lose its registry cross-check" ,
);
format! (
"DELETE FROM storage.blobs
WHERE ctid = ANY(
SELECT b.ctid FROM storage.blobs b
WHERE b.ref_count <= 0
AND (b.orphaned_at IS NULL
OR b.orphaned_at < now() - ($2::int * interval '1 second'))
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
)
AND NOT EXISTS (
SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash
)
AND {unreferenced}
LIMIT $1
)
RETURNING hash, size"
)
}
2026-08-23 15:52:01 +02:00
fn manifest_reap_sql ( registry : & BlobReferenceRegistry ) -> String {
let orphaned = registry
. no_reference_predicate ( RefLevel ::Manifest , "m.file_hash" )
. expect (
"no manifest-level blob reference source registered: the reap \
predicate would match every manifest" ,
);
format! (
"DELETE FROM storage.chunk_manifests
WHERE ctid = ANY(
SELECT ctid
FROM storage.chunk_manifests m
2026-09-04 12:30:12 +02:00
WHERE {orphaned}
2026-08-23 15:52:01 +02:00
LIMIT $1
)
RETURNING file_hash, chunk_hashes, total_size"
)
}
2026-02-14 01:29:34 +01:00
pub struct DedupService {
2026-04-14 21:33:38 +02:00
/// Pluggable blob storage backend (local FS, S3, …).
backend : Arc < dyn BlobStorageBackend > ,
2026-02-24 19:28:00 +01:00
/// PostgreSQL connection pool (dedup index in `storage.blobs`) — primary,
2026-06-11 13:06:33 +00:00
/// used by request-path operations (store_from_stream, etc.).
2026-02-14 19:30:49 +01:00
pool : Arc < PgPool > ,
2026-02-24 19:28:00 +01:00
/// Isolated maintenance pool for long-running operations
/// (verify_integrity, garbage_collect) that must never starve the primary.
maintenance_pool : Arc < PgPool > ,
2026-05-22 13:10:47 +02:00
/// Single lifecycle dispatcher — fired on blob created / deleted.
blob_lifecycle : Option < Arc < BlobLifecycleService >> ,
2026-07-16 14:20:20 +00:00
/// `file_hash → ChunkManifest` for the read path — every stream / range
/// / full read of a CDC blob used to pay one manifest query first, even
/// for the media the gallery re-reads constantly. Positive-only (a
/// legacy blob gaining a manifest via background rechunking must be
/// seen immediately), weight-bounded (a manifest is ~72 B per chunk),
/// short TTL so GC'd manifests age out fast (benches/MANIFEST-CACHE.md).
manifest_cache : moka ::future ::Cache < String , Arc < ChunkManifest >> ,
2026-08-23 15:52:01 +02:00
/// Every table that holds blob references, so GC agrees with the
/// consistency jobs on what "referenced" means. Defaults to the two
/// built-in sources; DI replaces it once more tables exist. Never
/// optional — an empty registry would make "nothing references it"
/// vacuously true and the manifest sweep would reap everything.
reference_registry : Arc < BlobReferenceRegistry > ,
/// The manifest reap statement, built once from `reference_registry`.
/// Kept as a field so `garbage_collect` runs a fixed statement rather
/// than assembling SQL inside a delete loop — see `manifest_reap_sql`.
manifest_reap_sql : String ,
2026-09-04 21:43:49 +02:00
/// The chunk/blob reap statement (GC phase 2), same treatment — see
/// [`blob_reap_sql`], including why its registry predicate is additive
/// rather than a replacement for the hardcoded guards.
blob_reap_sql : String ,
2026-02-14 01:29:34 +01:00
}
impl DedupService {
2026-02-14 19:30:49 +01:00
/// Create a new dedup service backed by PostgreSQL.
2026-02-24 19:28:00 +01:00
///
2026-04-14 21:33:38 +02:00
/// * `backend` — pluggable blob storage (local filesystem, S3, etc.).
2026-02-24 19:28:00 +01:00
/// * `pool` — primary pool for request-path operations.
/// * `maintenance_pool` — isolated pool for verify_integrity / garbage_collect.
2026-04-14 21:33:38 +02:00
pub fn new (
backend : Arc < dyn BlobStorageBackend > ,
pool : Arc < PgPool > ,
maintenance_pool : Arc < PgPool > ,
) -> Self {
2026-08-23 15:52:01 +02:00
let registry = Arc ::new ( Self ::default_reference_registry ( pool . clone ()));
2026-02-14 01:29:34 +01:00
Self {
2026-04-14 21:33:38 +02:00
backend ,
2026-02-14 19:30:49 +01:00
pool ,
2026-02-24 19:28:00 +01:00
maintenance_pool ,
2026-05-22 13:10:47 +02:00
blob_lifecycle : None ,
2026-07-16 14:20:20 +00:00
manifest_cache : Self ::build_manifest_cache (),
2026-08-23 15:52:01 +02:00
reference_registry : registry . clone (),
manifest_reap_sql : manifest_reap_sql ( & registry ),
2026-09-04 21:43:49 +02:00
blob_reap_sql : blob_reap_sql ( & registry ),
2026-02-14 01:29:34 +01:00
}
}
2026-08-23 19:13:19 +02:00
/// Every built-in blob-reference source, in one place.
///
/// This is THE definition of "what references a blob" — DI does not
/// assemble its own, it reads this one back via
/// [`Self::reference_registry`] and hands it to the consistency jobs, so
/// GC and the sweeps cannot disagree. Keeping it as the construction
/// default also means every path — including tests — has a
/// manifest-level source, so the reap predicate can never degenerate to
/// "nothing references anything".
2026-08-23 15:52:01 +02:00
fn default_reference_registry ( pool : Arc < PgPool > ) -> BlobReferenceRegistry {
2026-08-23 19:13:19 +02:00
crate ::infrastructure ::repositories ::pg ::blob_reference_sources ::built_in_registry ( pool )
2026-08-23 15:52:01 +02:00
}
2026-07-16 14:20:20 +00:00
/// See the `manifest_cache` field docs. Weight ≈ real heap bytes of one
/// entry; 32 MiB cap ≈ tens of thousands of typical (sub-1 GB) files.
fn build_manifest_cache () -> moka ::future ::Cache < String , Arc < ChunkManifest >> {
moka ::future ::Cache ::builder ()
. weigher ( | key : & String , value : & Arc < ChunkManifest >| {
( key . len () + value . chunk_hashes . len () * 80 + 64 ) as u32
})
. max_capacity ( 32 * 1024 * 1024 )
. time_to_live ( std ::time ::Duration ::from_secs ( 60 ))
. build ()
}
2026-08-23 15:52:01 +02:00
/// Registers the blob-reference registry used by the manifest reap
/// predicate. Without it `garbage_collect` skips manifest collection
/// entirely — see `docs/plan/derived-blobs.md`.
pub fn with_reference_registry ( mut self , registry : Arc < BlobReferenceRegistry > ) -> Self {
self . manifest_reap_sql = manifest_reap_sql ( & registry );
2026-09-04 21:43:49 +02:00
self . blob_reap_sql = blob_reap_sql ( & registry );
2026-08-23 15:52:01 +02:00
self . reference_registry = registry ;
self
}
2026-08-23 21:22:26 +02:00
/// Store a server-derived artifact and record the mapping from the
/// content it was derived from.
///
/// One call does the whole contract, so no caller has to remember the
/// accounting:
///
/// 1. writes the bytes through the normal CDC path — derived blobs get
/// the same backend, encryption, migration and rotation as any other
/// content, and `store_from_stream` takes exactly one reference;
/// 2. records `(source_hash, kind, variant) -> blob_hash`;
/// 3. **releases that reference if the mapping already existed**, because
/// the row that would justify it is not ours — two instances racing
/// to render the same thumbnail must leave `ref_count` at 1, not 2.
///
/// `bytes` is expected to be small (a thumbnail is 3-90 KB, below
/// `CDC_MIN_CHUNK`, so this is a single chunk). See
/// `docs/plan/derived-blobs.md`.
///
/// Returns the derived blob hash.
2026-08-25 07:39:57 +02:00
/// Attach user-supplied bytes to a FILE — the file-keyed twin of
/// [`Self::store_derived_blob`].
///
/// Same storage path (the bytes are still content-addressed and still
/// deduplicated), different mapping: the row is keyed by `file_id`, so
/// two files holding identical attached bytes get two rows and two
/// references. Sharing the mapping is what must not happen — a
/// content-keyed client preview would let one user's upload be served
/// for another user's file.
///
/// `ON CONFLICT … DO UPDATE`, unlike the derived twin: re-uploading a
/// preview for the same `(file_id, kind, variant)` is a deliberate
/// replacement, whereas a re-derived thumbnail is the same bytes again.
/// The reference held by the row being replaced is released.
pub async fn store_attached_blob (
& self ,
file_id : & str ,
kind : & str ,
variant : & str ,
content_type : & str ,
bytes : Bytes ,
uploaded_by : uuid ::Uuid ,
) -> Result < String , DomainError > {
let stored = self
. store_from_stream (
stream ::once ( async move { Ok ::< Bytes , std ::io ::Error > ( bytes ) }),
Some ( content_type . to_string ()),
)
. await ? ;
let attached_hash = stored . hash (). to_string ();
2026-08-25 08:13:51 +02:00
// Read the hash being superseded BEFORE upserting.
//
// It cannot come from `RETURNING`: PostgreSQL only permits `EXCLUDED`
// in the `SET` and `WHERE` of `DO UPDATE`, so a RETURNING clause
// comparing old against new is a syntax error — and one that surfaces
// only at runtime, where this method's best-effort caller swallows it
// into a warning while the sidecar keeps the feature looking healthy.
//
// The gap between this SELECT and the upsert is benign: losing the
// race leaves one stale reference, which the manifest recompute
// reports rather than anything being lost or served wrongly.
let previous : Option < ( String ,) > = sqlx ::query_as (
"SELECT blob_hash FROM storage.file_attached_blobs
WHERE file_id = $1::uuid AND kind = $2 AND variant = $3" ,
)
. bind ( file_id )
. bind ( kind )
. bind ( variant )
. fetch_optional ( self . pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "read attached blob: {e} " ))) ? ;
sqlx ::query (
2026-08-25 07:39:57 +02:00
"INSERT INTO storage.file_attached_blobs
(file_id, kind, variant, blob_hash, content_type, uploaded_by)
VALUES ($1::uuid, $2, $3, $4, $5, $6)
ON CONFLICT (file_id, kind, variant) DO UPDATE
SET blob_hash = EXCLUDED.blob_hash,
content_type = EXCLUDED.content_type,
uploaded_by = EXCLUDED.uploaded_by,
2026-08-25 08:13:51 +02:00
created_at = now()" ,
2026-08-25 07:39:57 +02:00
)
. bind ( file_id )
. bind ( kind )
. bind ( variant )
. bind ( & attached_hash )
. bind ( content_type )
. bind ( uploaded_by )
2026-08-25 08:13:51 +02:00
. execute ( self . pool . as_ref ())
2026-08-25 07:39:57 +02:00
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "record attached blob: {e} " ))) ? ;
// A replaced row's old blob loses its only reference from here. Not
// releasing it would pin those bytes forever — nothing else points at
// a superseded preview.
2026-08-25 08:13:51 +02:00
if let Some (( old_hash ,)) = previous
2026-08-25 07:39:57 +02:00
&& old_hash != attached_hash
&& let Err ( e ) = self . remove_reference ( & old_hash ). await
{
tracing ::warn! (
target : "oxicloud::dedup" ,
error = % e ,
"failed to release replaced attached-blob reference for {}" ,
& old_hash [ .. old_hash . len (). min ( 12 )],
);
}
Ok ( attached_hash )
}
/// Look up bytes attached to a file. File-keyed counterpart of
/// [`Self::find_derived_blob`].
pub async fn find_attached_blob (
& self ,
file_id : & str ,
kind : & str ,
variant : & str ,
) -> Option < crate ::application ::ports ::dedup_ports ::DerivedBlobRef > {
sqlx ::query_as ::< _ , ( String , String ) > (
"SELECT blob_hash, content_type FROM storage.file_attached_blobs
WHERE file_id = $1::uuid AND kind = $2 AND variant = $3" ,
)
. bind ( file_id )
. 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 ,
}
})
}
2026-08-23 21:22:26 +02:00
pub async fn store_derived_blob (
& self ,
source_hash : & str ,
kind : & str ,
variant : & str ,
content_type : & str ,
bytes : Bytes ,
) -> Result < String , DomainError > {
let stored = self
. store_from_stream (
stream ::once ( async move { Ok ::< Bytes , std ::io ::Error > ( bytes ) }),
Some ( content_type . to_string ()),
)
. await ? ;
let derived_hash = stored . hash (). to_string ();
let inserted = sqlx ::query (
2026-08-28 12:43:05 +02:00
// The source must still EXIST, or this row can never be cleaned
// up. `purge_derived_blobs` runs from the source's reap, so a
// mapping written after that reap is unreachable forever: nothing
// will reap that hash a second time, and the orphaned row holds
// its derived blob's ref_count at 1, which GC is then correct to
// refuse. Permanent leak, three rows per image.
//
// It is not hypothetical. Background thumbnail generation is
// spawned and unawaited, so an upload deleted promptly — which a
// test suite does constantly, and users do occasionally — has its
// render finish AFTER the blob was reaped and then record a
// mapping to a corpse.
//
// Checking both tables because `source_hash` names a Blob:
// a manifest for CDC content, a bare blob row for legacy
// whole-file content.
//
// Zero rows here is indistinguishable from the ON CONFLICT case,
// and both want the same handling — release the reference the
// blob write just took — which the caller already does.
2026-08-23 21:22:26 +02:00
"INSERT INTO storage.content_derived_blobs
(source_hash, kind, variant, blob_hash, content_type)
2026-08-28 12:43:05 +02:00
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (SELECT 1 FROM storage.chunk_manifests WHERE file_hash = $1)
OR EXISTS (SELECT 1 FROM storage.blobs WHERE hash = $1)
2026-08-23 21:22:26 +02:00
ON CONFLICT (source_hash, kind, variant) DO NOTHING" ,
)
. bind ( source_hash )
. bind ( kind )
. bind ( variant )
. bind ( & derived_hash )
. bind ( content_type )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "record derived blob: {e} " ))) ?
. rows_affected ();
if inserted == 0 {
2026-08-28 12:43:05 +02:00
// Two causes, one correct response.
//
// Either someone else already mapped this variant (ON CONFLICT),
// or the source Blob no longer exists so the WHERE EXISTS above
// refused the row. Both leave our blob write with no mapping
// behind it, and in both cases keeping the reference would pin
// the blob forever — inflating ref_count on every re-render in
// the first case, stranding an unreachable blob in the second.
2026-08-23 21:22:26 +02:00
if let Err ( e ) = self . remove_reference ( & derived_hash ). await {
tracing ::warn! (
target : "oxicloud::dedup" ,
error = % e ,
"failed to release duplicate derived-blob reference for {}" ,
& derived_hash [ .. derived_hash . len (). min ( 12 )],
);
}
}
Ok ( derived_hash )
}
2026-08-23 23:10:00 +02:00
/// 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 > {
2026-08-29 11:37:33 +02:00
match self . lookup_derived ( source_hash , kind , variant ). await {
crate ::application ::ports ::dedup_ports ::DerivedLookup ::Found ( r ) => Some ( r ),
_ => None ,
}
}
/// Full three-way answer: no row, a negative verdict, or the blob.
///
/// Callers deciding whether to spend a decode want the middle case,
/// which [`Self::find_derived_blob`] cannot express — it folds
/// "never attempted" and "attempted, not worth it" into the same
/// `None`, and a caller acting on that repeats the expensive work
/// forever. Use this wherever the derivation is costly; use
/// `find_derived_blob` when you only need the bytes.
///
/// A query error reads as `Missing`, deliberately: a database blip
/// should cost a redundant render, never a wrong "not derivable"
/// that suppresses a derivation the content can support.
pub async fn lookup_derived (
& self ,
source_hash : & str ,
kind : & str ,
variant : & str ,
) -> crate ::application ::ports ::dedup_ports ::DerivedLookup {
use crate ::application ::ports ::dedup_ports ::{ DerivedBlobRef , DerivedLookup };
let row = sqlx ::query_as ::< _ , ( Option < String > , Option < String > ) > (
2026-08-23 23:10:00 +02:00
"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 ()
2026-08-29 11:37:33 +02:00
. flatten ();
match row {
None => DerivedLookup ::Missing ,
// The CHECK constraint keeps blob_hash and content_type NULL
// together, so one NULL is the whole negative row.
Some (( None , _ )) | Some (( _ , None )) => DerivedLookup ::NotDerivable ,
Some (( Some ( blob_hash ), Some ( content_type ))) => DerivedLookup ::Found ( DerivedBlobRef {
2026-08-23 23:10:00 +02:00
blob_hash ,
content_type ,
2026-08-29 11:37:33 +02:00
}),
}
}
/// Record that this derivation is not worth attempting again.
///
/// For outcomes that are deterministic in the source content — a
/// transcode that came out larger, a source that will not decode, a
/// source over the decode ceiling. **Never** for a timeout, a closed
/// semaphore, or an I/O error: those are properties of the moment,
/// and a row written for one marks good content underivable forever
/// with nothing to retry it.
///
/// Takes no reference on any Blob — there is no derived Blob to hold
/// one. The row is dependent on its source and is reaped with it,
/// same as a positive row.
///
/// Guarded by the same source-exists check as `store_derived_blob`:
/// a row whose source has already been reaped is a permanent leak of
/// a mapping nothing will ever clean up.
pub async fn store_derived_negative (
& self ,
source_hash : & str ,
kind : & str ,
variant : & str ,
) -> Result < (), DomainError > {
sqlx ::query (
"INSERT INTO storage.content_derived_blobs
(source_hash, kind, variant, blob_hash, content_type)
SELECT $1, $2, $3, NULL, NULL
WHERE EXISTS (SELECT 1 FROM storage.chunk_manifests WHERE file_hash = $1)
OR EXISTS (SELECT 1 FROM storage.blobs WHERE hash = $1)
ON CONFLICT (source_hash, kind, variant) DO NOTHING" ,
)
. bind ( source_hash )
. bind ( kind )
. bind ( variant )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "store_derived_negative: {e} " ))
}) ? ;
Ok (())
2026-08-23 23:10:00 +02:00
}
2026-08-23 16:13:27 +02:00
/// The registry backing the reap predicate.
///
/// Exposed so `blobs_consistency` recomputes refcounts from the *same*
/// source set GC reaps from. If the two ever diverged, the sweep would
/// bless counts the collector disagrees with — and the collector wins,
/// destructively.
pub fn reference_registry ( & self ) -> Arc < BlobReferenceRegistry > {
self . reference_registry . clone ()
}
2026-05-22 13:10:47 +02:00
/// Registers the blob lifecycle dispatcher (thumbnail cleanup, …).
pub fn with_blob_lifecycle ( mut self , lifecycle : Arc < BlobLifecycleService > ) -> Self {
self . blob_lifecycle = Some ( lifecycle );
2026-05-14 00:03:03 +02:00
self
}
2026-05-22 13:10:47 +02:00
fn fire_blob_creation_hooks ( & self , hash : & str , content_type : Option <& str > ) {
if let Some ( lc ) = & self . blob_lifecycle {
lc . on_blob_created ( hash , content_type );
2026-05-14 00:03:03 +02:00
}
}
2026-08-24 00:53:46 +02:00
/// Everything that must happen when a blob is permanently reaped:
/// drop the artifacts derived FROM it, then notify the lifecycle hooks.
///
/// Boxed because it is mutually recursive with `remove_reference`:
/// releasing a thumbnail's reference can reap the thumbnail's own blob,
/// which comes back through here. It terminates after one level —
/// nothing is derived from a thumbnail, so the inner purge finds no rows.
fn reap_blob < 'a > (
& 'a self ,
hash : & 'a str ,
) -> Pin < Box < dyn std ::future ::Future < Output = () > + Send + 'a >> {
Box ::pin ( async move {
self . purge_derived_blobs ( hash ). await ;
self . fire_blob_hooks ( hash );
})
}
/// Delete every artifact derived from `source_hash` and release the
/// manifest references those rows held.
///
/// The delete counterpart of [`Self::store_derived_blob`]. Without it a
/// thumbnail pins its own blob forever: the mapping row keeps
/// `chunk_manifests.ref_count` at 1 with no file behind it, so GC never
/// reclaims the bytes and a full delete leaves orphans on disk.
async fn purge_derived_blobs ( & self , source_hash : & str ) {
let derived : Vec < ( String ,) > = match sqlx ::query_as (
"DELETE FROM storage.content_derived_blobs
WHERE source_hash = $1
RETURNING blob_hash" ,
)
. bind ( source_hash )
. fetch_all ( self . pool . as_ref ())
. await
{
Ok ( rows ) => rows ,
Err ( e ) => {
tracing ::warn! (
target : "oxicloud::dedup" ,
error = % e ,
"failed to purge derived blobs for {}" ,
& source_hash [ .. source_hash . len (). min ( 12 )],
);
return ;
}
};
2026-08-28 12:43:05 +02:00
// Silent on success until now, which made three distinct outcomes
// indistinguishable from the outside: never called, called and found
// nothing, or found rows whose release then failed. Chasing an
// orphaned-derived-row leak cost several full suite runs for exactly
// that reason, so the call announces itself.
//
// `info` when it actually deleted something — that is rare (only when
// a source Blob dies) and it is the line that proves the reap path
// reached here. `debug` for the common no-op.
if derived . is_empty () {
tracing ::debug! (
target : "oxicloud::dedup" ,
"purge_derived_blobs: no rows for {}" ,
& source_hash [ .. source_hash . len (). min ( 12 )],
);
} else {
tracing ::info! (
target : "oxicloud::dedup" ,
rows = derived . len (),
"purge_derived_blobs: releasing {} derived row(s) for {}" ,
derived . len (),
& source_hash [ .. source_hash . len (). min ( 12 )],
);
}
2026-08-24 00:53:46 +02:00
for ( blob_hash ,) in derived {
if let Err ( e ) = self . remove_reference ( & blob_hash ). await {
tracing ::warn! (
target : "oxicloud::dedup" ,
error = % e ,
"failed to release derived blob {}" ,
& blob_hash [ .. blob_hash . len (). min ( 12 )],
);
}
}
}
2026-05-22 13:10:47 +02:00
fn fire_blob_hooks ( & self , hash : & str ) {
if let Some ( lc ) = & self . blob_lifecycle {
lc . on_blob_deleted ( hash );
2026-05-13 11:33:45 +02:00
}
}
2026-03-04 14:02:15 +01:00
/// Creates a stub instance for testing — never hits PG or the filesystem.
2026-06-24 02:44:25 +02:00
///
/// Gated for both build modes integration tests are reachable from:
/// the raw `cfg(integration_tests)` flag used by CI / justfile
/// (`RUSTFLAGS='--cfg integration_tests'`) and the
/// `feature = "integration_tests"` form for callers that flip the
/// cargo feature instead. Standard `cfg(test)` keeps unit-test use.
#[cfg(any(test, integration_tests, feature = "integration_tests" ))]
2026-03-04 14:02:15 +01:00
pub fn new_stub () -> Self {
2026-04-14 21:33:38 +02:00
use crate ::infrastructure ::services ::local_blob_backend ::LocalBlobBackend ;
2026-03-04 14:02:15 +01:00
let stub_pool = Arc ::new (
sqlx ::pool ::PoolOptions ::< sqlx ::Postgres > ::new ()
. max_connections ( 1 )
. connect_lazy ( "postgres://invalid:5432/none" )
. unwrap (),
);
2026-08-23 15:52:01 +02:00
let stub_registry = Arc ::new ( Self ::default_reference_registry ( stub_pool . clone ()));
2026-03-04 14:02:15 +01:00
Self {
2026-04-14 21:33:38 +02:00
backend : Arc ::new ( LocalBlobBackend ::new ( Path ::new ( "/tmp/oxicloud_stub_blobs" ))),
2026-03-04 14:02:15 +01:00
pool : stub_pool . clone (),
2026-08-23 15:52:01 +02:00
maintenance_pool : stub_pool . clone (),
2026-05-22 13:10:47 +02:00
blob_lifecycle : None ,
2026-07-16 14:20:20 +00:00
manifest_cache : Self ::build_manifest_cache (),
2026-08-23 15:52:01 +02:00
reference_registry : stub_registry . clone (),
manifest_reap_sql : manifest_reap_sql ( & stub_registry ),
2026-09-04 21:43:49 +02:00
blob_reap_sql : blob_reap_sql ( & stub_registry ),
2026-03-04 14:02:15 +01:00
}
}
2026-04-14 21:33:38 +02:00
/// Initialize the service (delegate to backend + log stats from PG).
2026-02-14 19:30:49 +01:00
pub async fn initialize ( & self ) -> Result < (), DomainError > {
2026-04-14 21:33:38 +02:00
self . backend . initialize (). await ? ;
2026-02-14 01:29:34 +01:00
2026-08-23 15:52:01 +02:00
// The reap statement is assembled from the registered reference
2026-08-23 17:19:08 +02:00
// sources, so it is not greppable in the source tree. It DELETES
// manifests, so log it unconditionally at info rather than hiding it
// behind a filter an operator has to know to enable — if what GC
// considers "referenced" ever changes, that must be visible on the
// next boot without anyone going looking.
//
// Whitespace-collapsed to a single field so a multi-line query does
// not sprawl across the boot log; expand it with
// `sed 's/ AND / AND\n /g'` or just paste it into psql.
tracing ::info! (
2026-08-23 15:52:01 +02:00
target : "oxicloud::dedup" ,
sources = ? self
. reference_registry
. sources ()
. iter ()
. map ( | s | s . source_name ())
. collect ::< Vec < _ >> (),
2026-08-23 17:19:08 +02:00
statement = % self
. manifest_reap_sql
. split_whitespace ()
. collect ::< Vec < _ >> ()
. join ( " " ),
"🧹 manifest reap predicate registered"
2026-08-23 15:52:01 +02:00
);
2026-04-14 23:17:39 +02:00
let blob_count : i64 = sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.blobs" )
2026-02-14 19:30:49 +01:00
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( 0 );
2026-04-14 23:17:39 +02:00
let blob_bytes : i64 =
2026-02-14 19:30:49 +01:00
sqlx ::query_scalar ( "SELECT COALESCE(SUM(size), 0) FROM storage.blobs" )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( 0 );
2026-02-14 01:29:34 +01:00
2026-04-14 23:17:39 +02:00
let manifest_count : i64 =
sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.chunk_manifests" )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( 0 );
2026-02-14 01:29:34 +01:00
tracing ::info! (
2026-04-14 23:17:39 +02:00
"Dedup service initialized (backend={}, CDC): {} chunk blobs ({} bytes), {} manifests" ,
2026-04-14 21:33:38 +02:00
self . backend . backend_type (),
2026-04-14 23:17:39 +02:00
blob_count ,
blob_bytes ,
manifest_count ,
2026-02-14 01:29:34 +01:00
);
Ok (())
}
2026-04-14 21:33:38 +02:00
/// Return a reference to the underlying blob storage backend.
pub fn backend ( & self ) -> & Arc < dyn BlobStorageBackend > {
& self . backend
}
2026-02-14 19:30:49 +01:00
// ── Path helpers ─────────────────────────────────────────────
2026-02-14 01:29:34 +01:00
2026-04-14 21:33:38 +02:00
/// Get the local blob path for a given hash (if the backend supports it).
2026-02-14 01:29:34 +01:00
pub fn blob_path ( & self , hash : & str ) -> PathBuf {
2026-04-14 21:33:38 +02:00
self . backend
. local_blob_path ( hash )
. unwrap_or_else ( || PathBuf ::from ( format! ( "remote:// {} " , hash )))
2026-02-14 01:29:34 +01:00
}
2026-02-14 19:30:49 +01:00
// ── Hash helpers ─────────────────────────────────────────────
2026-03-01 21:47:39 +01:00
/// Calculate BLAKE3 hash of a file (~5× faster than SHA-256).
2026-02-23 00:51:46 +01:00
///
2026-06-11 13:06:33 +00:00
/// Uses memory-mapped I/O with rayon parallelism. Used by
/// `verify_integrity` to re-hash local blob files.
2026-02-14 01:29:34 +01:00
pub async fn hash_file ( path : & Path ) -> std ::io ::Result < String > {
2026-02-23 00:51:46 +01:00
let path = path . to_path_buf ();
tokio ::task ::spawn_blocking ( move || {
2026-03-01 21:47:39 +01:00
let mut hasher = blake3 ::Hasher ::new ();
2026-03-06 22:14:43 +01:00
hasher . update_mmap_rayon ( & path ) ? ;
2026-03-01 21:47:39 +01:00
Ok ( hasher . finalize (). to_hex (). to_string ())
2026-02-23 00:51:46 +01:00
})
. await
. expect ( "hash_file: spawn_blocking task panicked" )
2026-02-14 01:29:34 +01:00
}
2026-06-11 13:06:33 +00:00
// ── Core store operations (streaming CDC) ───────────────────
2026-02-14 01:29:34 +01:00
2026-06-11 13:06:33 +00:00
/// Maximum concurrent chunk uploads to the blob backend.
const CHUNK_UPLOAD_CONCURRENCY : usize = 8 ;
/// Flush the pending distinct-chunk batch after this many chunks…
const FLUSH_MAX_CHUNKS : usize = 32 ;
/// …or after this many buffered bytes, whichever comes first. Together
/// with the ≤ 1 MiB chunk in flight this bounds peak RAM per upload to
/// ~9 MiB regardless of file size.
const FLUSH_MAX_BYTES : usize = 8 * 1024 * 1024 ;
2026-06-19 09:37:51 +00:00
/// Grace period (seconds) a blob must stay orphaned (`ref_count = 0`)
/// before [`garbage_collect`](Self::garbage_collect) may physically delete
/// it. Mirrors git's `gc.pruneExpire`: content that became unreferenced
/// only moments ago is never reaped, so a concurrent uploader about to pin
/// a just-orphaned chunk — or a delta-upload client that registered loose
/// chunks at `ref_count = 0` and is about to commit their manifest — cannot
/// race the sweep. Must comfortably exceed the longest plausible gap
/// between registering a chunk and referencing it (any in-flight upload).
2026-09-02 23:16:29 +02:00
///
/// `pub` because sibling consistency tenants derive their own grace
/// windows from this value — notably `blobs_consistency`'s
/// `blob_orphan_stalled` check, which flags rows that have been sitting
/// past `GC_ORPHAN_GRACE_SECS × 24` (a healthy sweep would never trip
/// that). Keeping the two grace values coupled at the constant, rather
/// than at two hand-tuned magic numbers, means tuning this one auto-
/// scales the stall threshold too.
pub const GC_ORPHAN_GRACE_SECS : i64 = 60 * 60 ; // 1 hour
2026-06-19 09:37:51 +00:00
2026-06-11 13:06:33 +00:00
/// Store content with CDC deduplication, straight from a byte stream —
/// the single write path for every upload surface (REST multipart,
/// WebDAV PUT, NextCloud PUT, chunked-upload assembly, WOPI PutFile).
2026-02-25 23:31:51 +01:00
///
2026-06-11 13:06:33 +00:00
/// One pass over the incoming bytes: FastCDC boundary detection,
/// per-chunk BLAKE3, the whole-file BLAKE3, dedup lookups and blob
/// writes all happen while the stream is still arriving. There is no
/// spool file and no re-read — each uploaded byte touches the disk at
/// most once, and not at all when the store already has its chunk.
2026-02-15 17:53:25 +01:00
///
2026-06-11 13:06:33 +00:00
/// Identical-content races (two clients uploading the same file
/// concurrently) are resolved at the manifest INSERT via ON CONFLICT:
/// the loser releases its chunk references and returns `ExistingBlob`.
pub async fn store_from_stream < S > (
2026-02-14 01:29:34 +01:00
& self ,
2026-06-11 13:06:33 +00:00
source : S ,
2026-02-14 01:29:34 +01:00
content_type : Option < String > ,
2026-06-11 13:06:33 +00:00
) -> Result < DedupResultDto , DomainError >
where
S : Stream < Item = Result < Bytes , std ::io ::Error >> + Send ,
{
let outcome = self . ingest_chunks_from_stream ( source ). await ? ;
2026-06-11 14:34:02 +00:00
tracing ::debug! (
"CDC stream ingested: {} ({} bytes, {} chunks, {} written)" ,
& outcome . file_hash [ .. 12 ],
outcome . total_size ,
outcome . chunk_hashes . len (),
outcome . newly_written ,
);
2026-06-11 13:06:33 +00:00
let distinct = outcome . distinct_hashes ();
2026-06-11 14:34:02 +00:00
self . attach_manifest (
& outcome . file_hash ,
& outcome . chunk_hashes ,
& outcome . chunk_sizes ,
outcome . total_size ,
content_type ,
& distinct ,
)
. await
}
2026-06-11 13:06:33 +00:00
2026-06-11 14:34:02 +00:00
/// Attach a manifest to chunk references the caller already holds (one
/// per distinct chunk hash) — the shared accounting tail of both
/// [`store_from_stream`] and the delta-upload commit.
///
/// On a lost insert race or an already-existing manifest, the existing
/// manifest's ref_count is bumped FIRST and only then are the held chunk
/// references released (`distinct_held`); the reverse order could leave
/// the caller's file row without any manifest reference behind it.
pub async fn attach_manifest (
& self ,
file_hash : & str ,
chunk_hashes : & [ String ],
chunk_sizes : & [ u64 ],
total_size : u64 ,
content_type : Option < String > ,
distinct_held : & [ String ],
) -> Result < DedupResultDto , DomainError > {
2026-06-11 13:06:33 +00:00
// A bounded retry covers the rare interleaving where the manifest
// that beat our INSERT is deleted again before our ref bump lands.
for _ in 0 .. 3 {
let inserted = sqlx ::query (
"INSERT INTO storage.chunk_manifests
(file_hash, chunk_hashes, chunk_sizes, total_size, chunk_count, content_type, ref_count)
VALUES ($1, $2, $3, $4, $5, $6, 1)
ON CONFLICT (file_hash) DO NOTHING" ,
)
2026-06-11 14:34:02 +00:00
. bind ( file_hash )
. bind ( chunk_hashes )
. bind ( chunk_sizes . iter (). map ( | s | * s as i64 ). collect ::< Vec < _ >> ())
2026-06-11 13:06:33 +00:00
. bind ( total_size as i64 )
2026-06-11 14:34:02 +00:00
. bind ( chunk_hashes . len () as i32 )
2026-06-11 13:06:33 +00:00
. bind ( & content_type )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to insert manifest: {} " , e ))
}) ?
. rows_affected ();
if inserted > 0 {
tracing ::info! (
2026-06-11 14:34:02 +00:00
"NEW BLOB (CDC): {} ({} bytes, {} chunks)" ,
2026-06-11 13:06:33 +00:00
& file_hash [ .. 12 ],
total_size ,
2026-06-11 14:34:02 +00:00
chunk_hashes . len (),
2026-06-11 13:06:33 +00:00
);
2026-06-11 14:34:02 +00:00
self . fire_blob_creation_hooks ( file_hash , content_type . as_deref ());
2026-06-11 13:06:33 +00:00
return Ok ( DedupResultDto ::NewBlob {
2026-06-11 14:34:02 +00:00
hash : file_hash . to_string (),
2026-06-11 13:06:33 +00:00
size : total_size ,
});
2026-04-14 23:17:39 +02:00
}
2026-02-14 19:30:49 +01:00
2026-06-11 13:06:33 +00:00
// The manifest already exists — either this exact content was
// stored before or an identical concurrent upload just won the
2026-06-11 14:34:02 +00:00
// race. Bump ITS ref_count, then hand back the held references.
if let Some ( existing_size ) = self . bump_manifest_if_exists ( file_hash ). await ? {
self . release_chunk_refs ( self . pool . as_ref (), distinct_held )
. await ;
2026-06-11 13:06:33 +00:00
tracing ::info! (
"DEDUP HIT (manifest): {} ({} bytes saved)" ,
& file_hash [ .. 12 ],
existing_size ,
);
return Ok ( DedupResultDto ::ExistingBlob {
2026-06-11 14:34:02 +00:00
hash : file_hash . to_string (),
2026-06-11 13:06:33 +00:00
size : existing_size as u64 ,
saved_bytes : existing_size as u64 ,
});
}
}
2026-02-14 19:30:49 +01:00
2026-06-11 14:34:02 +00:00
self . release_chunk_refs ( self . pool . as_ref (), distinct_held )
. await ;
2026-06-11 13:06:33 +00:00
Err ( DomainError ::internal_error (
"Dedup" ,
format! ( "Manifest insert/bump kept racing for {file_hash} " ),
))
}
2026-02-14 01:29:34 +01:00
2026-06-11 14:34:02 +00:00
// ── Delta-upload primitives ──────────────────────────────────
//
// The delta protocol ("upload only what changed") lets a client claim
// chunks by hash instead of sending their bytes. Two invariants keep
// that from becoming a content oracle or a poisoning vector:
//
// 1. **Ownership**: without bytes, a caller may only claim chunks that
2026-06-16 22:29:51 +02:00
// are already reachable through their own files (live OR trashed,
// since trash is a deferred-delete state — the user can restore the
// file at any time, so the content is still theirs), or unreferenced
// orphans (ref_count = 0 — i.e. "I just uploaded it"). Everything
// else must be uploaded; the store dedups it on write.
2026-06-11 14:34:02 +00:00
// 2. **Verification**: a declared file_hash is never trusted — the
// commit re-reads the proposed chunk sequence server-side and
// recomputes BLAKE3 before any manifest row exists. A forged hash
// would otherwise poison future whole-file dedup hits for OTHER
// users uploading the genuine content.
2026-06-11 16:38:02 +00:00
//
// The download direction reuses invariant 1: a chunk's bytes are only
// served to callers whose own files already reference it.
/// The ordered chunk list composing `file_hash`, for the delta-download
/// manifest: `(chunks[(hash, size)], total_size)`.
///
/// Legacy whole-file blobs (pre-CDC, not yet re-chunked) are presented
/// as a single-chunk manifest of themselves — the chunk download path
/// can serve them directly, so sync clients need no special case.
pub async fn manifest_chunk_list (
& self ,
file_hash : & str ,
) -> Result < Option < ( Vec < ( String , u64 ) > , u64 ) > , DomainError > {
let manifest = sqlx ::query_as ::< _ , ( Vec < String > , Vec < i64 > , i64 ) > (
"SELECT chunk_hashes, chunk_sizes, total_size
FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( file_hash )
. fetch_optional ( self . pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Manifest lookup: {e} " ))) ? ;
if let Some (( hashes , sizes , total )) = manifest {
let chunks = hashes
. into_iter ()
. zip ( sizes . into_iter (). map ( | s | s as u64 ))
. collect ();
return Ok ( Some (( chunks , total as u64 )));
}
// Legacy fallback: the blob is its own single chunk.
let legacy = sqlx ::query_scalar ::< _ , i64 > ( "SELECT size FROM storage.blobs WHERE hash = $1" )
. bind ( file_hash )
. fetch_optional ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Legacy blob lookup: {e} " ))
}) ? ;
Ok ( legacy . map ( | size | ( vec! [( file_hash . to_string (), size as u64 )], size as u64 )))
}
/// Sizes of the given chunk hashes from the dedup index, keyed by hash.
/// Hashes without a row are simply absent from the result.
pub async fn chunk_sizes (
& self ,
hashes : & [ String ],
) -> Result < std ::collections ::HashMap < String , u64 > , DomainError > {
if hashes . is_empty () {
return Ok ( std ::collections ::HashMap ::new ());
}
sqlx ::query_as ::< _ , ( String , i64 ) > (
"SELECT hash, size FROM storage.blobs WHERE hash = ANY($1)" ,
)
. bind ( hashes )
. fetch_all ( self . pool . as_ref ())
. await
. map ( | rows | rows . into_iter (). map ( | ( h , s ) | ( h , s as u64 )). collect ())
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "chunk_sizes query: {e} " )))
}
2026-07-16 16:50:07 +00:00
/// Read-ahead depth the backend recommends for multi-chunk drains
/// (1 local, 8 for request-latency-bound object stores) — see
/// `BlobStorageBackend::read_prefetch` and benches/BLOB-PREFETCH.md.
pub fn read_prefetch ( & self ) -> usize {
self . backend . read_prefetch ()
}
2026-06-11 16:38:02 +00:00
/// Stream one chunk's raw bytes from the backend. The caller is
/// responsible for entitlement (see [`claimable_chunks`]).
pub async fn chunk_stream (
& self ,
hash : & str ,
) -> Result < Pin < Box < dyn Stream < Item = Result < Bytes , std ::io ::Error >> + Send >> , DomainError >
{
self . backend . get_blob_stream ( hash ). await
}
2026-06-11 14:34:02 +00:00
/// Of `hashes` (distinct), the subset `caller_id` may claim without
2026-07-02 21:09:54 +02:00
/// uploading bytes: chunks referenced by manifests of files in drives
/// where the caller holds a **writable role** (owner / editor /
/// contributor), or directly referenced as (legacy) whole-file blobs
/// under the same predicate. Backed by the GIN index on
2026-06-16 22:29:51 +02:00
/// `chunk_manifests.chunk_hashes`.
///
2026-07-02 21:09:54 +02:00
/// Post-D7 (`project_d7_policy_calls` LOCKED design): entitlement is
/// drive-membership + writable-role, not the legacy `user_id`
/// filter. Viewers/commenters are excluded — they can't legitimately
/// upload content into a drive, so they can't claim
/// "already-uploaded" via dedup. Group memberships (direct +
/// transitive) are expanded inline through
/// `storage.caller_group_ids($2)`.
///
2026-06-16 22:29:51 +02:00
/// Trashed files count as ownership: a trashed file's content is still
2026-07-02 21:09:54 +02:00
/// under the caller's writable scope (restorable until trash-empty),
/// so a re-upload of the same content should hit the dedup fast path
/// instead of forcing the caller to re-send bytes they already have
/// on the server. Must stay in lockstep with [`pin_claimable_chunks`],
/// which actually bumps the ref_count using the same entitlement set.
2026-06-11 14:34:02 +00:00
pub async fn claimable_chunks (
& self ,
caller_id : uuid ::Uuid ,
hashes : & [ String ],
) -> Result < HashSet < String > , DomainError > {
if hashes . is_empty () {
return Ok ( HashSet ::new ());
}
sqlx ::query_scalar ::< _ , String > (
"SELECT c.h FROM UNNEST($1::text[]) AS c(h)
WHERE EXISTS (
SELECT 1
FROM storage.files f
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
2026-07-02 21:09:54 +02:00
WHERE m.chunk_hashes @> ARRAY[c.h]
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
2026-06-11 14:34:02 +00:00
)
OR EXISTS (
SELECT 1 FROM storage.files f2
2026-07-02 21:09:54 +02:00
WHERE f2.blob_hash = c.h
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f2.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
2026-06-11 14:34:02 +00:00
)" ,
)
. bind ( hashes )
. bind ( caller_id )
. fetch_all ( self . pool . as_ref ())
. await
. map ( | rows | rows . into_iter (). collect ())
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "claimable_chunks query: {e} " )))
}
/// Pin one reference on each of `hashes` (distinct) that the caller is
2026-07-02 21:09:54 +02:00
/// entitled to claim — writably-scoped chunks (see [`claimable_chunks`])
/// or unreferenced orphans (`ref_count = 0`, the just-uploaded state).
2026-06-11 14:34:02 +00:00
/// One statement: entitlement check and bump are atomic per row, so a
/// concurrent last-reference delete can never be resurrected and a
/// non-entitled hash is simply not returned.
///
2026-07-02 21:09:54 +02:00
/// Post-D7 (`project_d7_policy_calls` LOCKED): entitlement uses the
/// same drive-membership + writable-role predicate as
/// [`claimable_chunks`] — MUST STAY IN LOCKSTEP with that query.
/// Group memberships resolve through `storage.caller_group_ids($2)`.
///
/// Entitlement includes files in trash: a trashed file is still
/// within the caller's writable scope, the content is still theirs
/// to re-reference, and the race with trash-empty is handled the
/// same way as `add_reference` — if GC has already deleted the blob
/// row, the UPDATE affects 0 rows and the hash is simply absent from
/// the returned set.
2026-06-16 22:29:51 +02:00
///
2026-06-11 14:34:02 +00:00
/// Returns the set actually pinned; the caller compares against its
/// input and reports the difference as `still_missing`.
pub async fn pin_claimable_chunks (
& self ,
caller_id : uuid ::Uuid ,
hashes : & [ String ],
) -> Result < HashSet < String > , DomainError > {
if hashes . is_empty () {
return Ok ( HashSet ::new ());
}
sqlx ::query_scalar ::< _ , String > (
"UPDATE storage.blobs b
SET ref_count = ref_count + 1
WHERE b.hash = ANY($1)
AND ( b.ref_count = 0
OR EXISTS (
SELECT 1
FROM storage.files f
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
2026-07-02 21:09:54 +02:00
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
2026-06-11 14:34:02 +00:00
)
OR EXISTS (
SELECT 1 FROM storage.files f2
2026-07-02 21:09:54 +02:00
WHERE f2.blob_hash = b.hash
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f2.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
2026-06-11 14:34:02 +00:00
) )
RETURNING b.hash" ,
)
. bind ( hashes )
. bind ( caller_id )
. fetch_all ( self . pool . as_ref ())
. await
. map ( | rows | rows . into_iter (). collect ())
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "pin_claimable_chunks query: {e} " ))
})
}
/// Release one reference per distinct hash — the public counterpart of
/// [`pin_claimable_chunks`] for aborted commits. Best-effort.
pub async fn release_pinned_chunks ( & self , hashes : & [ String ]) {
self . release_chunk_refs ( self . pool . as_ref (), hashes ). await ;
}
/// Store client-provided loose chunks (delta upload, step 2).
///
/// Each element of `frames` is one chunk's raw bytes (the wire framing
/// is the interface layer's concern). The hash is ALWAYS computed
/// server-side — a declared hash is never trusted for content
/// addressing. Chunks are written unsynced, made durable with one
/// batched sweep, then registered at `ref_count = 0`: unreferenced
/// orphans that either get pinned by a following commit or swept by
/// the periodic GC if the client never returns. `ON CONFLICT DO
/// NOTHING` keeps existing rows' reference counts untouched.
///
/// Returns `(hash, size)` per frame, in input order.
pub async fn store_loose_chunks < S > ( & self , frames : S ) -> Result < Vec < ( String , u64 ) > , DomainError >
where
S : Stream < Item = Result < Bytes , DomainError >> + Send ,
{
futures ::pin_mut! ( frames );
let mut received : Vec < ( String , u64 ) > = Vec ::new ();
let mut new_rows : Vec < ( String , i64 ) > = Vec ::new ();
2026-07-20 08:48:42 +00:00
// Intra-request dedup set keyed on the raw 32-byte BLAKE3 digest
// (`[u8; 32]`, `Copy` — no per-distinct-chunk 64-byte `String` heap
// key), mirroring the streaming ingest loop (benches/ROUND17.md §D2).
// hex ↔ digest is bijective, so membership is identical to the old
// `HashSet<String>`.
let mut seen : HashSet < [ u8 ; 32 ] > = HashSet ::new ();
2026-06-11 14:34:02 +00:00
while let Some ( frame ) = frames . next (). await {
let data = frame ? ;
if data . len () > CDC_MAX_CHUNK {
return Err ( DomainError ::validation_error ( format! (
"Chunk frame of {} bytes exceeds the {CDC_MAX_CHUNK} -byte maximum" ,
data . len ()
)));
}
2026-07-20 08:48:42 +00:00
let digest = blake3 ::hash ( & data );
let hash = digest . to_hex (). to_string ();
let len = data . len ();
if seen . insert ( * digest . as_bytes ()) {
2026-06-11 14:34:02 +00:00
self . backend
. put_blob_from_bytes_unsynced ( & hash , data )
. await ? ;
2026-07-20 08:48:42 +00:00
// First occurrence: `received` needs a copy, `new_rows` moves it.
received . push (( hash . clone (), len as u64 ));
new_rows . push (( hash , len as i64 ));
} else {
// Duplicate within this request — move the hex into `received`
// (no clone; the blob is already registered by its first
// occurrence). Same `received` sequence, input order preserved.
received . push (( hash , len as u64 ));
2026-06-11 14:34:02 +00:00
}
}
if ! new_rows . is_empty () {
// Durability before visibility — same invariant as the ingest
// engine: no PG row may ever point at unsynced bytes.
2026-07-20 15:25:42 +00:00
// `new_rows` is owned and dead after this block — unzip (move the
// hash Strings out) instead of cloning each one for the reshape +
// UNNEST bind. (benches/ROUND23.md §U1)
let ( hashes , sizes ) : ( Vec < String > , Vec < i64 > ) = new_rows . into_iter (). unzip ();
2026-06-11 14:34:02 +00:00
self . backend . sync_blobs ( & hashes ). await ? ;
sqlx ::query (
2026-06-19 09:37:51 +00:00
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
2026-06-11 14:34:02 +00:00
ON CONFLICT (hash) DO NOTHING" ,
)
. bind ( & hashes )
. bind ( & sizes )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to register chunks: {e} " ))
}) ? ;
}
Ok ( received )
}
/// Verification read for the delta commit: stream the proposed chunk
/// sequence from the backend, recompute the whole-file BLAKE3 and
/// capture the first bytes for MIME sniffing. The caller must hold a
/// pin on every chunk (so a concurrent GC cannot pull bytes out from
/// under the read). Also validates each chunk's actual size against
/// the declared one — the manifest's Range arithmetic depends on it.
pub async fn hash_chunk_sequence (
& self ,
2026-07-19 19:33:41 +00:00
chunks : Vec < ( String , u64 ) > ,
2026-06-11 14:34:02 +00:00
sniff_len : usize ,
) -> Result < ( String , Vec < u8 > ), DomainError > {
let mut hasher = blake3 ::Hasher ::new ();
let mut head : Vec < u8 > = Vec ::with_capacity ( sniff_len . min ( 16 * 1024 ));
2026-07-16 16:50:07 +00:00
// Overlap the NEXT chunk's open with the current chunk's hash+drain
// — the same `buffered(read_prefetch)` combinator as the download
// path (benches/BLOB-PREFETCH.md measured +7-12 % on local disk;
// request-latency-bound object stores gain far more). Hashing stays
// strictly in manifest order: `buffered` yields in input order.
let prefetch = self . backend . read_prefetch (). max ( 1 );
let backend = self . backend . clone ();
2026-07-19 19:33:41 +00:00
let mut opened = futures ::stream ::iter ( chunks )
2026-07-16 16:50:07 +00:00
. map ( move | ( hash , declared_size ) | {
let backend = backend . clone ();
async move {
backend
. get_blob_stream ( & hash )
. await
. map ( | s | ( hash , declared_size , s ))
}
})
. buffered ( prefetch );
while let Some ( next ) = opened . next (). await {
let ( hash , declared_size , mut stream ) = next ? ;
let ( hash , declared_size ) = ( & hash , & declared_size );
2026-06-11 14:34:02 +00:00
let mut actual : u64 = 0 ;
while let Some ( part ) = stream . next (). await {
let part = part . map_err ( | e | {
DomainError ::internal_error (
"Dedup" ,
format! ( "Verification read of chunk {hash} : {e} " ),
)
}) ? ;
actual += part . len () as u64 ;
hasher . update ( & part );
if head . len () < sniff_len {
let take = ( sniff_len - head . len ()). min ( part . len ());
head . extend_from_slice ( & part [ .. take ]);
}
}
if actual != * declared_size {
return Err ( DomainError ::validation_error ( format! (
"Chunk {hash} is {actual} bytes, manifest declares {declared_size} "
)));
}
}
Ok (( hasher . finalize (). to_hex (). to_string (), head ))
}
2026-06-11 13:06:33 +00:00
/// Bump a manifest's ref_count if it exists; returns its total_size.
/// Single statement — no window between the existence check and the bump.
async fn bump_manifest_if_exists ( & self , file_hash : & str ) -> Result < Option < i64 > , DomainError > {
sqlx ::query_scalar ::< _ , i64 > (
"UPDATE storage.chunk_manifests SET ref_count = ref_count + 1
WHERE file_hash = $1
RETURNING total_size" ,
2026-02-14 19:30:49 +01:00
)
2026-06-11 13:06:33 +00:00
. bind ( file_hash )
. fetch_optional ( self . pool . as_ref ())
2026-02-14 19:30:49 +01:00
. await
. map_err ( | e | {
2026-06-11 13:06:33 +00:00
DomainError ::internal_error ( "Dedup" , format! ( "Failed to bump manifest ref_count: {e} " ))
2026-04-14 23:17:39 +02:00
})
}
2026-06-11 13:06:33 +00:00
/// Stream → chunk store, WITHOUT creating a manifest.
///
/// Splits the stream with FastCDC while computing per-chunk and
/// whole-stream BLAKE3 hashes, then settles each batch of distinct
/// chunks against PG:
2026-04-14 23:17:39 +02:00
///
2026-06-11 13:06:33 +00:00
/// 1. ONE `UPDATE … RETURNING` per batch pins every already-known chunk
/// (`ref_count + 1` — protecting it from a concurrent last-reference
/// delete for the rest of the upload) and atomically classifies the
/// remaining hashes as new. No check-then-bump TOCTOU window.
/// 2. New chunks are written to the backend unsynced with bounded
/// concurrency; chunks the store already has are dropped from RAM
/// without any disk I/O.
/// 3. At end of stream, ONE `sync_blobs` sweep makes the new chunks
/// durable, then ONE batched INSERT registers them (`ON CONFLICT`
/// bumps instead — a concurrent identical upload may have registered
/// the same brand-new chunk first). Durability before visibility.
///
/// `ref_count` is taken once per *distinct* chunk — symmetric with
/// `remove_manifest_reference`, which decrements via
/// `WHERE hash = ANY(chunk_hashes)` (each row once). A repeated chunk
/// (zero-filled regions, concatenated archives) must not over-count or
/// the blob leaks forever.
///
/// If the returned references are not attached to a manifest, the caller
/// must hand them back via `release_chunk_refs`. If this future is
/// dropped mid-stream (client disconnect), the internal guard rolls the
/// session back in a spawned task.
async fn ingest_chunks_from_stream < S > (
2026-04-14 23:17:39 +02:00
& self ,
2026-06-11 13:06:33 +00:00
source : S ,
) -> Result < ChunkIngestOutcome , DomainError >
where
S : Stream < Item = Result < Bytes , std ::io ::Error >> + Send ,
{
2026-07-16 16:50:07 +00:00
let guard = IngestGuard ::new ( self . pool . clone (), self . backend . clone ());
2026-06-11 13:06:33 +00:00
let reader = StreamReader ::new ( Box ::pin ( source ));
let mut chunker = fastcdc ::v2020 ::AsyncStreamCDC ::new (
reader ,
CDC_MIN_CHUNK ,
CDC_AVG_CHUNK ,
CDC_MAX_CHUNK ,
);
let chunk_stream = chunker . as_stream ();
futures ::pin_mut! ( chunk_stream );
let mut file_hasher = blake3 ::Hasher ::new ();
let mut total_size : u64 = 0 ;
let mut chunk_hashes : Vec < String > = Vec ::new ();
let mut chunk_sizes : Vec < u64 > = Vec ::new ();
2026-07-19 19:33:41 +00:00
// Keyed on the raw 32-byte BLAKE3 digest (`Copy`, no heap) rather than
// the 64-char hex String: the intra-upload dedup set no longer clones a
// String per chunk, holds 32-byte inline keys, and hashes 32 bytes not
// 64 on every membership test (benches/ROUND17.md §D2).
let mut session_seen : HashSet < [ u8 ; 32 ] > = HashSet ::new ();
2026-06-11 13:06:33 +00:00
let mut pending : Vec < ( String , Bytes ) > = Vec ::new ();
let mut pending_bytes : usize = 0 ;
2026-07-16 16:50:07 +00:00
// Depth-1 settle pipeline: batch N settles on a spawned task while
// the loop keeps reading/chunking/hashing batch N+1 from the source
// — the inline shape froze the reader (and the client's socket) for
// every settle (benches/INGEST-OVERLAP.md). The task records into
// the guard's shared state under its lock, so rollback stays exact
// even if this future is dropped mid-settle.
let mut in_flight : Option < tokio ::task ::JoinHandle < Result < (), DomainError >>> = None ;
/// Await the previous batch's settle, mapping panics/aborts to a
/// domain error so both are compensated identically.
async fn join_settle (
handle : tokio ::task ::JoinHandle < Result < (), DomainError >> ,
) -> Result < (), DomainError > {
match handle . await {
Ok ( res ) => res ,
Err ( e ) => Err ( DomainError ::internal_error (
"Dedup" ,
format! ( "Chunk settle task failed: {e} " ),
)),
}
}
2026-06-11 13:06:33 +00:00
while let Some ( item ) = chunk_stream . next (). await {
let chunk = match item {
Ok ( chunk ) => chunk ,
Err ( e ) => {
2026-07-16 16:50:07 +00:00
if let Some ( handle ) = in_flight . take () {
let _ = join_settle ( handle ). await ;
}
2026-06-11 13:06:33 +00:00
guard . rollback (). await ;
return Err ( DomainError ::internal_error (
"Dedup" ,
format! ( "Upload stream failed: {e} " ),
));
}
};
let data = chunk . data ;
total_size += data . len () as u64 ;
// Per-chunk hashing is ≤ 1 MiB of BLAKE3 (< 1 ms) — cheaper than
// a spawn_blocking round-trip per chunk.
file_hasher . update ( & data );
2026-07-19 19:33:41 +00:00
let digest = blake3 ::hash ( & data );
let hash = digest . to_hex (). to_string ();
2026-06-11 13:06:33 +00:00
chunk_sizes . push ( data . len () as u64 );
2026-07-19 19:33:41 +00:00
// The hex `hash` is materialised once. A genuinely new chunk needs
// it in three places — the ordered manifest, the dedup set key and
// the backend write — but the set keys on the raw digest (no clone),
// so only `chunk_hashes` is cloned before `pending` takes the
// original. A duplicate within this upload needs it only for the
// manifest: the `else` moves it in, no clone (benches/ROUND17.md §D2).
if session_seen . insert ( * digest . as_bytes ()) {
2026-06-11 13:06:33 +00:00
pending_bytes += data . len ();
2026-07-19 19:33:41 +00:00
chunk_hashes . push ( hash . clone ());
2026-06-11 13:06:33 +00:00
pending . push (( hash , Bytes ::from ( data )));
if pending . len () >= Self ::FLUSH_MAX_CHUNKS || pending_bytes >= Self ::FLUSH_MAX_BYTES
{
2026-07-16 16:50:07 +00:00
if let Some ( handle ) = in_flight . take ()
&& let Err ( e ) = join_settle ( handle ). await
{
guard . rollback (). await ;
return Err ( e );
}
let batch = std ::mem ::take ( & mut pending );
let handle = tokio ::spawn ( Self ::settle_batch (
self . pool . clone (),
self . backend . clone (),
guard . state . clone (),
batch ,
));
// Bench/ops escape hatch: OXICLOUD_INGEST_OVERLAP=0
// reproduces the old inline-settle behaviour (await the
// batch before reading on) — used by
// benches/INGEST-OVERLAP.md for an in-binary A/B.
if ingest_overlap_enabled () {
in_flight = Some ( handle );
} else if let Err ( e ) = join_settle ( handle ). await {
2026-06-11 13:06:33 +00:00
guard . rollback (). await ;
return Err ( e );
}
pending_bytes = 0 ;
}
2026-07-19 19:33:41 +00:00
} else {
// Duplicate within this upload: only the ordered manifest needs
// the hash. Move it in — no set/pending copy, zero extra allocs.
chunk_hashes . push ( hash );
2026-06-11 13:06:33 +00:00
}
}
2026-04-14 23:17:39 +02:00
2026-07-16 16:50:07 +00:00
if let Some ( handle ) = in_flight . take ()
&& let Err ( e ) = join_settle ( handle ). await
{
guard . rollback (). await ;
return Err ( e );
}
if let Err ( e ) = Self ::settle_batch (
self . pool . clone (),
self . backend . clone (),
guard . state . clone (),
std ::mem ::take ( & mut pending ),
)
. await
{
2026-06-11 13:06:33 +00:00
guard . rollback (). await ;
return Err ( e );
2026-04-14 23:17:39 +02:00
}
2026-06-11 13:06:33 +00:00
// ── Durability before visibility for the new chunks ──────
// One batched fsync sweep (no-op for remote backends, durable on
// PUT), then one batched INSERT. A crash before the INSERT leaves
// only unreferenced files; never a row pointing at unsynced bytes.
2026-07-16 16:50:07 +00:00
// No settle is in flight past this point — the lock is uncontended.
let ( new_hashes , new_sizes ) : ( Vec < String > , Vec < i64 > ) = {
let st = guard . state . lock (). await ;
(
st . written . iter (). map ( | ( h , _ ) | h . clone ()). collect (),
st . written . iter (). map ( | ( _ , s ) | * s ). collect (),
)
};
if ! new_hashes . is_empty () {
2026-06-11 13:06:33 +00:00
if let Err ( e ) = self . backend . sync_blobs ( & new_hashes ). await {
guard . rollback (). await ;
return Err ( e );
}
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
let registered = sqlx ::query (
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 1 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO UPDATE
2026-06-19 09:37:51 +00:00
SET ref_count = storage.blobs.ref_count + 1, orphaned_at = NULL" ,
2026-06-11 13:06:33 +00:00
)
. bind ( & new_hashes )
. bind ( & new_sizes )
. execute ( self . pool . as_ref ())
. await ;
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
if let Err ( e ) = registered {
guard . rollback (). await ;
return Err ( DomainError ::internal_error (
"Dedup" ,
format! ( "Failed to register chunks: {e} " ),
));
}
2026-04-14 23:17:39 +02:00
}
2026-07-16 16:50:07 +00:00
let newly_written = new_hashes . len ();
2026-06-11 13:06:33 +00:00
guard . disarm ();
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
Ok ( ChunkIngestOutcome {
file_hash : file_hasher . finalize (). to_hex (). to_string (),
total_size ,
chunk_hashes ,
chunk_sizes ,
newly_written ,
})
}
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
/// Settle one batch of distinct in-RAM chunks against PG + the backend.
2026-06-09 14:19:14 +00:00
///
2026-07-16 16:50:07 +00:00
/// Static (no `&self`) so the ingest loop can run it on a spawned task
/// and keep consuming the source stream while the batch settles — the
/// inline shape stalled the reader for the whole settle every 8 MiB
/// (benches/INGEST-OVERLAP.md). The shared-state lock is held for the
/// entire batch: pinned hashes and written chunks are recorded
/// progressively under it, so a failure (or a rollback racing this
/// settle) leaves nothing untracked.
async fn settle_batch (
pool : Arc < PgPool > ,
backend : Arc < dyn BlobStorageBackend > ,
state : Arc < tokio ::sync ::Mutex < IngestState >> ,
batch : Vec < ( String , Bytes ) > ,
2026-06-11 13:06:33 +00:00
) -> Result < (), DomainError > {
2026-07-16 16:50:07 +00:00
if batch . is_empty () {
2026-06-11 13:06:33 +00:00
return Ok (());
2026-06-09 14:19:14 +00:00
}
2026-07-16 16:50:07 +00:00
let mut guard = state . lock (). await ;
2026-06-11 13:06:33 +00:00
// Pin-or-classify in one statement: rows that exist take this
// session's reference NOW; hashes not returned don't exist and are
2026-07-20 08:48:42 +00:00
// ours to write. Bind borrowed `&str`s — sqlx encodes `&[&str]` to
// `text[]` identically to the owned Strings the old `.clone()` built,
// so no per-chunk hash String is allocated just to run the query
// (the pattern favorites_pg_repository.rs:271 already uses). The
// borrow is scoped so it ends before `batch` is moved below.
let pinned : HashSet < String > = {
let hashes : Vec <& str > = batch . iter (). map ( | ( h , _ ) | h . as_str ()). collect ();
sqlx ::query_scalar ::< _ , String > (
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL
WHERE hash = ANY($1)
RETURNING hash" ,
)
. bind ( & hashes )
. fetch_all ( pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to pin existing chunks: {e} " ))
}) ?
. into_iter ()
. collect ()
};
2026-06-09 14:19:14 +00:00
2026-06-11 13:06:33 +00:00
let mut to_write : Vec < ( String , Bytes ) > = Vec ::with_capacity ( batch . len ());
for ( hash , data ) in batch {
if pinned . contains ( & hash ) {
guard . pinned . push ( hash );
} else {
to_write . push (( hash , data ));
}
}
if to_write . is_empty () {
return Ok (());
}
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
// Unsynced writes — durability comes from the single end-of-stream
// sweep, before any PG row references these chunks.
let results : Vec < Result < ( String , i64 ), DomainError >> = stream ::iter ( to_write )
. map ( | ( hash , data ) | {
2026-06-07 01:03:52 +02:00
let backend = backend . clone ();
async move {
2026-06-11 13:06:33 +00:00
let len = data . len () as i64 ;
backend . put_blob_from_bytes_unsynced ( & hash , data ). await ? ;
Ok (( hash , len ))
2026-04-14 23:17:39 +02:00
}
2026-02-25 23:31:51 +01:00
})
2026-04-14 23:17:39 +02:00
. buffer_unordered ( Self ::CHUNK_UPLOAD_CONCURRENCY )
. collect ()
. await ;
2026-06-11 13:06:33 +00:00
let mut first_err : Option < DomainError > = None ;
2026-04-14 23:17:39 +02:00
for result in results {
2026-06-11 13:06:33 +00:00
match result {
Ok ( row ) => guard . written . push ( row ),
Err ( e ) => first_err = first_err . or ( Some ( e )),
}
2026-06-10 09:55:02 +00:00
}
2026-06-11 13:06:33 +00:00
match first_err {
Some ( e ) => Err ( e ),
None => Ok (()),
2026-02-25 23:31:51 +01:00
}
2026-02-14 01:29:34 +01:00
}
2026-02-14 19:30:49 +01:00
// ── Reference counting ───────────────────────────────────────
2026-02-14 01:29:34 +01:00
2026-04-14 23:17:39 +02:00
/// Check if a blob with the given hash exists (manifest or legacy).
2026-02-14 19:30:49 +01:00
pub async fn blob_exists ( & self , hash : & str ) -> bool {
2026-04-14 23:17:39 +02:00
// Check manifest first
let manifest = sqlx ::query_scalar ::< _ , bool > (
"SELECT EXISTS(SELECT 1 FROM storage.chunk_manifests WHERE file_hash = $1)" ,
)
. bind ( hash )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( false );
if manifest {
return true ;
}
// Legacy blob
2026-02-14 19:30:49 +01:00
sqlx ::query_scalar ::< _ , bool > ( "SELECT EXISTS(SELECT 1 FROM storage.blobs WHERE hash = $1)" )
. bind ( hash )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( false )
}
2026-07-02 21:18:01 +02:00
/// Returns `true` if the caller has a **writable role** on at least one
/// drive containing a (possibly trashed) file that references the blob
/// identified by `hash`.
///
/// Post-D7 (`project_d7_policy_calls` LOCKED): same
/// drive-membership + writable-role predicate as
/// [`claimable_chunks`] / [`pin_claimable_chunks`] — MUST stay in
/// lockstep with them. Group memberships (direct + transitive)
/// expand inline via `storage.caller_group_ids($2)`. Viewers /
/// commenters are excluded — they can't legitimately upload into
/// a drive, so they can't claim "already-uploaded" via dedup.
2026-03-05 13:15:34 +01:00
pub async fn user_owns_blob_reference ( & self , hash : & str , user_id : & str ) -> bool {
sqlx ::query_scalar ::< _ , bool > (
2026-07-02 21:18:01 +02:00
"SELECT EXISTS(
SELECT 1
FROM storage.files f
WHERE f.blob_hash = $1
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2::uuid)))
)
)
)" ,
2026-03-05 13:15:34 +01:00
)
. bind ( hash )
. bind ( user_id )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( false )
}
2026-06-20 16:33:08 +02:00
/// Batch variant of [`Self::user_owns_blob_reference`]: given candidate
2026-07-02 21:18:01 +02:00
/// hashes, return the subset the caller can already reference — in ONE
/// query (backed by `idx_files_blob_hash`). Lets a client hash a whole
/// upload set and learn which files it can skip with a single round trip
/// instead of one probe per file.
2026-06-20 16:33:08 +02:00
///
2026-07-02 21:18:01 +02:00
/// Post-D7: same drive-membership + writable-role predicate as the
/// single check. Anti-enumeration is preserved — only hashes present
/// in a drive the caller can write to come back.
2026-06-20 16:33:08 +02:00
pub async fn user_owned_blob_references (
& self ,
hashes : & [ String ],
user_id : & str ,
) -> Vec < String > {
if hashes . is_empty () {
return Vec ::new ();
}
sqlx ::query_scalar ::< _ , String > (
2026-07-02 21:18:01 +02:00
"SELECT DISTINCT f.blob_hash
FROM storage.files f
WHERE f.blob_hash = ANY($1)
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2::uuid)))
)
)" ,
2026-06-20 16:33:08 +02:00
)
. bind ( hashes )
. bind ( user_id )
. fetch_all ( self . pool . as_ref ())
. await
. unwrap_or_default ()
}
2026-04-14 23:17:39 +02:00
/// Get metadata for a blob (manifest-aware with legacy fallback).
2026-02-14 19:30:49 +01:00
pub async fn get_blob_metadata ( & self , hash : & str ) -> Option < BlobMetadataDto > {
2026-04-14 23:17:39 +02:00
// Check manifest first
let manifest = sqlx ::query_as ::< _ , ( i64 , i32 , Option < String > ) > (
"SELECT total_size, ref_count, content_type
FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( hash )
. fetch_optional ( self . pool . as_ref ())
. await
. ok ()
. flatten ();
if let Some (( total_size , ref_count , content_type )) = manifest {
return Some ( BlobMetadataDto {
hash : hash . to_owned (),
size : total_size as u64 ,
ref_count : ref_count as u32 ,
content_type ,
});
}
// Legacy blob
2026-02-14 19:30:49 +01:00
let row = sqlx ::query_as ::< _ , ( String , i64 , i32 , Option < String > ) > (
"SELECT hash, size, ref_count, content_type FROM storage.blobs WHERE hash = $1" ,
)
. bind ( hash )
. fetch_optional ( self . pool . as_ref ())
. await
. ok ()
. flatten () ? ;
Some ( BlobMetadataDto {
hash : row . 0 ,
size : row . 1 as u64 ,
ref_count : row . 2 as u32 ,
content_type : row . 3 ,
})
2026-02-14 01:29:34 +01:00
}
2026-04-14 23:17:39 +02:00
/// Add a reference (manifest-aware with legacy fallback).
2026-02-14 19:30:49 +01:00
pub async fn add_reference ( & self , hash : & str ) -> Result < (), DomainError > {
2026-04-14 23:17:39 +02:00
// Try manifest first
let manifest_affected = sqlx ::query (
"UPDATE storage.chunk_manifests SET ref_count = ref_count + 1 WHERE file_hash = $1" ,
)
. bind ( hash )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to add manifest ref: {} " , e ))
}) ?
. rows_affected ();
if manifest_affected > 0 {
return Ok (());
}
// Legacy blob
2026-02-14 19:30:49 +01:00
let rows_affected =
2026-06-19 09:37:51 +00:00
sqlx ::query (
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL WHERE hash = $1" ,
)
2026-02-14 19:30:49 +01:00
. bind ( hash )
. execute ( self . pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error (
"Dedup" ,
format! ( "Failed to increment ref_count: {} " , e ),
)
}) ?
. rows_affected ();
if rows_affected == 0 {
return Err ( DomainError ::new (
ErrorKind ::NotFound ,
"Blob" ,
format! ( "Blob not found: {} " , hash ),
));
2026-02-14 01:29:34 +01:00
}
Ok (())
}
2026-04-14 23:17:39 +02:00
/// Remove a reference from a blob (manifest-aware with legacy fallback).
///
/// For CDC manifests: decrements manifest ref_count. When it reaches 0
/// the manifest is deleted and all chunk ref_counts are decremented;
2026-06-19 09:58:51 +00:00
/// chunks that reach 0 are left for [`garbage_collect`](Self::garbage_collect)
/// to reclaim once they have been orphaned past the grace window — unlinking
/// them here would race a concurrent upload re-referencing the same chunk.
2026-02-14 19:30:49 +01:00
///
2026-06-19 09:58:51 +00:00
/// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`. A legacy
/// whole-file hash can never be re-created by an ingest (uploads are always
/// CDC now), so its file is unlinked eagerly — there is no writer to race.
2026-02-14 19:30:49 +01:00
pub async fn remove_reference ( & self , hash : & str ) -> Result < bool , DomainError > {
2026-04-14 23:17:39 +02:00
// ── CDC manifest path ────────────────────────────────────
let manifest = sqlx ::query_as ::< _ , ( i32 , Vec < String > ) > (
"SELECT ref_count, chunk_hashes FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( hash )
. fetch_optional ( self . pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Manifest lookup: {} " , e ))) ? ;
if let Some (( ref_count , chunk_hashes )) = manifest {
return self
. remove_manifest_reference ( hash , ref_count , & chunk_hashes )
. await ;
}
// ── Legacy whole-file blob path ──────────────────────────
self . remove_legacy_reference ( hash ). await
}
2026-06-19 09:58:51 +00:00
/// Remove a manifest reference. When the last reference is removed the
/// manifest is deleted and its chunks are dereferenced, but the chunk files
/// are NOT unlinked here: a chunk hash can be re-uploaded concurrently, so
/// unlinking right after the commit would race that re-reference (the same
/// TOCTOU the GC grace window guards). Newly-orphaned chunks are stamped and
/// reclaimed by [`garbage_collect`](Self::garbage_collect).
2026-04-14 23:17:39 +02:00
async fn remove_manifest_reference (
& self ,
file_hash : & str ,
_initial_ref_count : i32 ,
chunk_hashes : & [ String ],
) -> Result < bool , DomainError > {
let mut tx = self . pool . begin (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to begin TX: {} " , e ))
}) ? ;
// Lock manifest row
let current_rc = sqlx ::query_scalar ::< _ , i32 > (
"SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1 FOR UPDATE" ,
)
. bind ( file_hash )
. fetch_optional ( & mut * tx )
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Lock manifest: {} " , e ))) ? ;
let Some ( current_rc ) = current_rc else {
tx . rollback (). await . ok ();
return Ok ( false );
};
if current_rc <= 1 {
2026-06-19 09:58:51 +00:00
// Last reference — delete the manifest and dereference its chunks.
2026-04-14 23:17:39 +02:00
sqlx ::query ( "DELETE FROM storage.chunk_manifests WHERE file_hash = $1" )
. bind ( file_hash )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Delete manifest: {} " , e ))
}) ? ;
2026-06-19 09:58:51 +00:00
// Decrement chunk ref_counts and stamp orphaned_at on the ones that
// reach 0. We deliberately do NOT delete the chunk rows or unlink
// their files here: a chunk hash can be re-uploaded concurrently, so
// unlinking right after this commit would race that re-reference
// (the TOCTOU the grace window guards). garbage_collect() reclaims
// them safely once orphaned past the grace window. GREATEST clamps
// the single-chunk case where the PG file-delete trigger already
// decremented the row (file_hash == chunk_hash).
sqlx ::query (
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)" ,
2026-04-14 23:17:39 +02:00
)
. bind ( chunk_hashes )
2026-06-19 09:58:51 +00:00
. execute ( & mut * tx )
2026-04-14 23:17:39 +02:00
. await
2026-06-19 09:58:51 +00:00
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Decrement chunks: {} " , e ))) ? ;
2026-04-14 23:17:39 +02:00
tx . commit ()
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Commit: {} " , e ))) ? ;
2026-07-16 14:20:20 +00:00
// Post-commit so a concurrent read can't re-cache the manifest
// between invalidation and the delete becoming visible.
self . manifest_cache . invalidate ( file_hash ). await ;
2026-06-19 09:58:51 +00:00
// File content is gone — drop its blob-keyed thumbnails now.
2026-08-24 00:53:46 +02:00
self . reap_blob ( file_hash ). await ;
2026-04-27 20:41:19 +02:00
2026-04-14 23:17:39 +02:00
tracing ::info! (
2026-06-19 09:58:51 +00:00
"MANIFEST DELETED: {} ({} chunks dereferenced; orphans reclaimed by GC)" ,
2026-04-14 23:17:39 +02:00
& file_hash [ .. 12 ],
2026-06-19 09:58:51 +00:00
chunk_hashes . len ()
2026-04-14 23:17:39 +02:00
);
Ok ( true )
} else {
// Still has references — just decrement
sqlx ::query (
"UPDATE storage.chunk_manifests SET ref_count = ref_count - 1 WHERE file_hash = $1" ,
)
. bind ( file_hash )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Decrement manifest: {} " , e ))
}) ? ;
tx . commit ()
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Commit: {} " , e ))) ? ;
tracing ::debug! ( "Reference removed from manifest {}" , & file_hash [ .. 12 ]);
Ok ( false )
}
}
/// Remove a reference from a legacy whole-file blob.
async fn remove_legacy_reference ( & self , hash : & str ) -> Result < bool , DomainError > {
2026-02-14 19:30:49 +01:00
let mut tx = self . pool . begin (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to begin transaction: {} " , e ))
}) ? ;
2026-06-11 13:06:33 +00:00
// Lock the row exclusively — prevents a concurrent ingest from
2026-02-14 19:30:49 +01:00
// incrementing ref_count while we might be deleting
let row = sqlx ::query_as ::< _ , ( i32 , i64 ) > (
"SELECT ref_count, size FROM storage.blobs WHERE hash = $1 FOR UPDATE" ,
)
. bind ( hash )
. fetch_optional ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to lock blob row: {} " , e ))
}) ? ;
let Some (( ref_count , _size )) = row else {
// Blob doesn't exist — nothing to do
tx . rollback (). await . ok ();
return Ok ( false );
2026-02-14 01:29:34 +01:00
};
2026-02-14 19:30:49 +01:00
let new_ref_count = ( ref_count - 1 ). max ( 0 );
2026-02-14 01:29:34 +01:00
2026-02-14 19:30:49 +01:00
if new_ref_count == 0 {
// Last reference — delete row from PG
sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = $1" )
. bind ( hash )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error (
"Dedup" ,
format! ( "Failed to delete blob row: {} " , e ),
)
}) ? ;
tx . commit (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to commit: {} " , e ))
}) ? ;
2026-04-14 21:33:38 +02:00
// Delete blob from backend AFTER committing PG — the row is gone,
2026-06-11 13:06:33 +00:00
// so no concurrent ingest can resurrect a reference.
2026-04-14 21:33:38 +02:00
if let Err ( e ) = self . backend . delete_blob ( hash ). await {
2026-02-14 19:30:49 +01:00
tracing ::warn! ( "Failed to delete blob file {}: {}" , hash , e );
2026-02-14 01:29:34 +01:00
}
2026-05-13 11:33:45 +02:00
// Bug 3 fix: notify hooks — e.g. thumbnail cleanup keyed by hash
2026-08-24 00:53:46 +02:00
self . reap_blob ( hash ). await ;
2026-04-27 20:41:19 +02:00
2026-02-14 19:30:49 +01:00
tracing ::info! ( "BLOB DELETED: {} (no more references)" , & hash [ .. 12 ]);
2026-02-14 01:29:34 +01:00
Ok ( true )
} else {
2026-02-14 19:30:49 +01:00
// Still has references — just decrement
sqlx ::query ( "UPDATE storage.blobs SET ref_count = $1 WHERE hash = $2" )
. bind ( new_ref_count )
. bind ( hash )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error (
"Dedup" ,
format! ( "Failed to decrement ref_count: {} " , e ),
)
}) ? ;
tx . commit (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to commit: {} " , e ))
}) ? ;
tracing ::debug! ( "Reference removed from blob {}" , & hash [ .. 12 ]);
2026-02-14 01:29:34 +01:00
Ok ( false )
}
}
2026-05-13 11:33:45 +02:00
/// Targeted cleanup for a single blob after the PG trigger has already
/// decremented its ref_count. Deletes the blob row, disk file, and
/// blob-keyed thumbnails if ref_count has reached 0.
///
/// Handles both the legacy whole-file blob path (storage.blobs) and the
/// CDC manifest path (storage.chunk_manifests). Best-effort: logs
/// warnings on failure rather than returning an error.
pub async fn cleanup_if_orphaned ( & self , hash : & str ) {
let short = & hash [ .. hash . len (). min ( 12 )];
2026-08-23 22:44:39 +02:00
// 2026-08-23 refactor: this function used to compensate for the
// OLD PG trigger `trg_files_decrement_blob_ref` unconditionally
// decrementing `storage.blobs.ref_count`, which was wrong for
// CDC files (their `blob_hash` names a `chunk_manifests.file_hash`,
// not a chunk-in-a-manifest). The compensation branches would:
// * Decrement `chunk_manifests.ref_count` a SECOND time (the
// trigger having wrongly touched blobs, not the manifest);
// * Undo the trigger's blob decrement (rc > 1 branch);
// * Call `remove_manifest_reference` (rc <= 1 branch), which
// deletes manifest + dereferences chunks — again duplicating
// work the trigger should own.
//
// Migration `20261017000000_file_delete_trigger_manifest_aware.sql`
// rewrote the trigger to be manifest-aware, so it now correctly
// decrements EITHER the manifest OR the blob depending on which
// one the hash names, walks chunks on last-ref manifest delete,
// and leaves the counters in a consistent state without any
// compensation call. Running the old compensation ON TOP of the
// new trigger causes double-decrement / double-delete and is
// exactly what broke `dedup_blob_cleanup.hurl` step 7
// (`ref_count == 1` observed 0 after purging one of two dedup
// uploads).
//
// What remains here: **physical cleanup only**. If the trigger
// brought a LEGACY whole-file blob to ref_count = 0 and no
// manifest still references it (either directly via file_hash or
// indirectly as a chunk in another manifest's chunk_hashes[]),
// reap the DB row and the backend file eagerly. For CDC chunks
// whose ref_count reached 0 via the trigger's last-ref manifest
// path, `dedup_gc` handles physical reap with a grace window
// against re-upload races.
//
// Callers can keep invoking `cleanup_if_orphaned` unconditionally
// — for CDC paths it's a cheap no-op (manifest still exists OR
// the hash never had a blob row), for legacy paths it reaps.
2026-05-13 11:33:45 +02:00
let deleted_blob = sqlx ::query_scalar ::< _ , String > (
2026-08-23 22:44:39 +02:00
"DELETE FROM storage.blobs \
WHERE hash = $1 \
AND ref_count <= 0 \
AND NOT EXISTS (SELECT 1 FROM storage.chunk_manifests \
WHERE $1 = ANY(chunk_hashes)) \
RETURNING hash" ,
2026-05-13 11:33:45 +02:00
)
. bind ( hash )
. fetch_optional ( self . pool . as_ref ())
. await
. unwrap_or ( None );
if deleted_blob . is_some () {
if let Err ( e ) = self . backend . delete_blob ( hash ). await {
tracing ::warn! ( "cleanup_if_orphaned: disk delete failed for {short}: {e}" );
}
2026-08-24 00:53:46 +02:00
self . reap_blob ( hash ). await ;
2026-08-23 22:44:39 +02:00
tracing ::info! ( "cleanup_if_orphaned: removed orphaned legacy blob {short}" );
2026-05-13 11:33:45 +02:00
}
}
2026-02-14 19:30:49 +01:00
// ── Read operations ──────────────────────────────────────────
2026-02-14 01:29:34 +01:00
2026-06-22 00:01:56 +02:00
/// Build an in-order, prefetched byte stream over a CDC file's chunks.
///
/// Read-ahead depth is the backend's hint (1 for local disk, higher for
/// remote object stores where overlapping fetches hide per-chunk latency).
/// Shared by [`Self::read_blob_stream`] and [`Self::read_blob_bytes`] so both
/// build the chunk stream identically from a manifest's `chunk_hashes`.
2026-07-18 16:12:04 +00:00
/// Takes the shared manifest `Arc` and iterates its hashes by index —
/// the old `Vec<String>` signature forced every read to deep-clone the
/// whole hash list out of the cached manifest before the first byte
/// (N ~64-B String allocs per read of an N-chunk file); the per-chunk
/// `Arc` bump here is a single atomic increment.
2026-06-22 00:01:56 +02:00
fn stream_chunks (
& self ,
2026-07-18 16:12:04 +00:00
manifest : Arc < ChunkManifest > ,
2026-06-22 00:01:56 +02:00
) -> Pin < Box < dyn Stream < Item = Result < Bytes , std ::io ::Error >> + Send >> {
let prefetch = self . backend . read_prefetch (). max ( 1 );
let backend = self . backend . clone ();
2026-07-18 16:12:04 +00:00
let chunk_stream = stream ::iter ( 0 .. manifest . chunk_hashes . len ())
. map ( move | i | {
2026-06-22 00:01:56 +02:00
let backend = backend . clone ();
2026-07-18 16:12:04 +00:00
let manifest = manifest . clone ();
2026-06-22 00:01:56 +02:00
async move {
backend
2026-07-18 16:12:04 +00:00
. get_blob_stream ( & manifest . chunk_hashes [ i ])
2026-06-22 00:01:56 +02:00
. await
. map_err ( | e | std ::io ::Error ::other ( e . to_string ()))
}
})
. buffered ( prefetch )
. try_flatten ();
Box ::pin ( chunk_stream )
}
2026-07-16 14:20:20 +00:00
/// Cached manifest fetch for the read path (see the `manifest_cache`
/// field docs). `None` = legacy whole-file blob — never cached, so a
/// background rechunk that creates a manifest is honoured immediately.
2026-07-18 16:12:04 +00:00
///
/// Misses are single-flighted through `try_get_with`: K concurrent cold
/// readers of one newly-hot file (e.g. parallel Range probes on a big
/// video) coalesce onto ONE manifest SELECT instead of K. The
/// positive-only contract is preserved by routing "no manifest row" and
/// DB failures through the loader's error channel, which moka never
/// caches. The zero-alloc `get` fast path stays in front so warm reads
/// don't pay the owned-key clone `try_get_with` requires.
2026-07-16 14:20:20 +00:00
async fn manifest_cached ( & self , hash : & str ) -> Result < Option < Arc < ChunkManifest >> , DomainError > {
if let Some ( m ) = self . manifest_cache . get ( hash ). await {
return Ok ( Some ( m ));
}
2026-07-18 16:12:04 +00:00
enum MissKind {
Legacy ,
Db ( String ),
}
let pool = self . pool . clone ();
let query_hash = hash . to_string ();
let result = self
. manifest_cache
. try_get_with ( hash . to_string (), async move {
let row = sqlx ::query_as ::< _ , ( Vec < String > , Vec < i64 > , i64 ) > (
"SELECT chunk_hashes, chunk_sizes, total_size
FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( & query_hash )
. fetch_optional ( pool . as_ref ())
. await
. map_err ( | e | MissKind ::Db ( e . to_string ())) ? ;
match row {
Some (( chunk_hashes , chunk_sizes , total_size )) => Ok ( Arc ::new ( ChunkManifest {
chunk_hashes ,
chunk_sizes ,
total_size ,
})),
None => Err ( MissKind ::Legacy ),
}
})
. await ;
match result {
Ok ( m ) => Ok ( Some ( m )),
Err ( miss ) => match &* miss {
MissKind ::Legacy => Ok ( None ),
MissKind ::Db ( msg ) => Err ( DomainError ::internal_error (
"Dedup" ,
format! ( "Manifest lookup: {} " , msg ),
)),
},
2026-07-16 14:20:20 +00:00
}
}
2026-04-14 23:17:39 +02:00
/// Stream blob content — CDC-aware with legacy fallback.
///
2026-07-16 14:20:20 +00:00
/// For CDC files: looks up the manifest (RAM-cached), then streams
/// chunks in order, concatenating them into a single byte stream.
2026-04-14 23:17:39 +02:00
/// For legacy blobs: delegates directly to the backend.
2026-02-15 17:53:25 +01:00
pub async fn read_blob_stream (
& self ,
hash : & str ,
) -> Result < Pin < Box < dyn Stream < Item = Result < Bytes , std ::io ::Error >> + Send >> , DomainError >
{
2026-07-16 14:20:20 +00:00
match self . manifest_cached ( hash ). await ? {
2026-07-18 16:12:04 +00:00
Some ( m ) => Ok ( self . stream_chunks ( m )),
2026-04-14 23:17:39 +02:00
// Legacy whole-file blob
2026-06-22 00:01:56 +02:00
None => self . backend . get_blob_stream ( hash ). await ,
2026-04-14 23:17:39 +02:00
}
2026-02-15 17:53:25 +01:00
}
2026-04-26 11:55:05 +02:00
/// Read the full blob into memory — CDC-aware with legacy fallback.
///
/// This is intended for image-oriented workflows such as thumbnail
/// generation where the downstream library already requires the full
/// payload in memory to decode the image.
2026-06-22 00:01:56 +02:00
///
/// A single manifest query fetches BOTH the size hint (for the buffer
/// pre-allocation) and the chunk list — they live in the same
/// `chunk_manifests` PK row, so reading them separately (the old
/// `blob_size` + `read_blob_stream`) doubled the manifest round-trips on
/// every full-blob read (e.g. 2N queries for an N-image gallery cold load).
2026-04-26 11:55:05 +02:00
pub async fn read_blob_bytes ( & self , hash : & str ) -> Result < Bytes , DomainError > {
2026-07-16 14:20:20 +00:00
let ( mut stream , expected_size ) = match self . manifest_cached ( hash ). await ? {
2026-07-18 16:12:04 +00:00
Some ( m ) => {
let expected = m . total_size . max ( 0 ) as usize ;
( self . stream_chunks ( m ), expected )
}
2026-06-22 00:01:56 +02:00
None => {
// Legacy whole-file blob: size + stream straight from the backend.
let size = self . backend . blob_size ( hash ). await ? as usize ;
( self . backend . get_blob_stream ( hash ). await ? , size )
}
};
2026-04-26 11:55:05 +02:00
2026-06-22 00:01:56 +02:00
let mut data = Vec ::with_capacity ( expected_size );
2026-04-26 11:55:05 +02:00
while let Some ( chunk ) = stream . next (). await {
let chunk = chunk . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to read blob chunk: {} " , e ))
}) ? ;
data . extend_from_slice ( & chunk );
}
Ok ( Bytes ::from ( data ))
}
2026-08-02 19:20:23 +02:00
/// Stream a blob to a temp file for extractors that only accept a
/// filesystem `Path` (id3, mp3_duration, ffprobe, nom-exif video).
/// CDC-aware — reads through [`Self::read_blob_stream`] so a chunked
/// file's chunks are concatenated on the fly. Peak process-heap =
/// one chunk (~1 MiB) regardless of blob size.
///
/// `temp_dir` is the destination directory (typically
/// `AppConfig::temp_dir`, from env `OXICLOUD_TEMP_DIR`). `suffix`
/// is appended to the tempfile name (e.g. `".mp3"`, `".jpg"`) so
/// content-sniffing extractors get a hint. The returned
/// `NamedTempFile` auto-removes on drop; callers pass `.path()`
/// to the extractor, then let the guard fall out of scope.
pub async fn stream_blob_to_tempfile (
& self ,
hash : & str ,
temp_dir : & std ::path ::Path ,
suffix : & str ,
) -> Result < tempfile ::NamedTempFile , DomainError > {
use tokio ::io ::AsyncWriteExt ;
let named = tempfile ::Builder ::new ()
. prefix ( "oxi-blob-" )
. suffix ( suffix )
. tempfile_in ( temp_dir )
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "mktemp in {:?} : {e} " , temp_dir ))
}) ? ;
// Re-open with tokio's async File so we can await writes.
let path = named . path (). to_path_buf ();
let mut file = tokio ::fs ::OpenOptions ::new ()
. write ( true )
. truncate ( true )
. open ( & path )
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "reopen temp: {e} " ))) ? ;
// CDC-aware: manifest lookup + chunk concat OR legacy backend passthrough.
let mut stream = self . read_blob_stream ( hash ). await ? ;
while let Some ( chunk ) = stream . next (). await {
let bytes = chunk
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "stream chunk: {e} " ))) ? ;
file . write_all ( & bytes )
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "temp write: {e} " ))) ? ;
}
file . flush ()
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "temp flush: {e} " ))) ? ;
drop ( file );
Ok ( named )
}
2026-04-14 23:17:39 +02:00
/// Stream a byte range — CDC-aware with legacy fallback.
///
/// For CDC files: calculates which chunks overlap the requested range,
/// then streams only the relevant portions.
2026-02-15 17:53:25 +01:00
pub 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 >
{
2026-07-16 14:20:20 +00:00
if let Some ( m ) = self . manifest_cached ( hash ). await ? {
2026-07-18 16:12:04 +00:00
let end = end . unwrap_or ( m . total_size as u64 );
2026-04-14 23:17:39 +02:00
2026-07-18 16:12:04 +00:00
// Calculate which chunks overlap [start, end). Chunks are
// addressed by manifest INDEX (the hash is read through the
// shared `Arc` at fetch time) — a `bytes=0-` probe of an
// N-chunk video used to clone all N hash Strings here.
2026-04-14 23:17:39 +02:00
let mut offset : u64 = 0 ;
2026-07-18 16:12:04 +00:00
// (chunk_index, range_start_within_chunk, range_end_within_chunk)
let mut selected : Vec < ( usize , u64 , Option < u64 > ) > = Vec ::new ();
2026-04-14 23:17:39 +02:00
2026-07-18 16:12:04 +00:00
for ( i , & chunk_size ) in m . chunk_sizes . iter (). enumerate () {
2026-04-14 23:17:39 +02:00
let chunk_size = chunk_size as u64 ;
let chunk_end = offset + chunk_size ;
if chunk_end > start && offset < end {
let range_start = start . saturating_sub ( offset );
let range_end = if chunk_end > end {
Some ( end - offset )
} else {
None
};
2026-07-18 16:12:04 +00:00
selected . push (( i , range_start , range_end ));
2026-04-14 23:17:39 +02:00
}
offset += chunk_size ;
if offset >= end {
break ;
}
}
2026-06-15 10:56:33 +00:00
// Stream selected chunks with ranges. Read-ahead depth from the
// backend hint (local=1; remote overlaps fetches — see read_blob_stream).
let prefetch = self . backend . read_prefetch (). max ( 1 );
2026-04-14 23:17:39 +02:00
let backend = self . backend . clone ();
let chunk_stream = stream ::iter ( selected )
2026-07-18 16:12:04 +00:00
. map ( move | ( i , range_start , range_end ) | {
2026-04-14 23:17:39 +02:00
let backend = backend . clone ();
2026-07-18 16:12:04 +00:00
let manifest = m . clone ();
2026-04-14 23:17:39 +02:00
async move {
backend
2026-07-18 16:12:04 +00:00
. get_blob_range_stream (
& manifest . chunk_hashes [ i ],
range_start ,
range_end ,
)
2026-04-14 23:17:39 +02:00
. await
. map_err ( | e | std ::io ::Error ::other ( e . to_string ()))
}
})
2026-06-15 10:56:33 +00:00
. buffered ( prefetch )
2026-04-14 23:17:39 +02:00
. try_flatten ();
Ok ( Box ::pin ( chunk_stream ))
} else {
// Legacy whole-file blob
self . backend . get_blob_range_stream ( hash , start , end ). await
}
2026-02-15 17:53:25 +01:00
}
2026-04-14 23:17:39 +02:00
/// Get blob size — manifest-aware with legacy fallback.
2026-02-15 17:53:25 +01:00
pub async fn blob_size ( & self , hash : & str ) -> Result < u64 , DomainError > {
2026-07-16 14:20:20 +00:00
// Check manifest first (RAM cache, else one O(1) PG row)
if let Some ( m ) = self . manifest_cached ( hash ). await ? {
return Ok ( m . total_size as u64 );
2026-04-14 23:17:39 +02:00
}
// Legacy: delegate to backend
2026-04-14 21:33:38 +02:00
self . backend . blob_size ( hash ). await
2026-02-15 17:53:25 +01:00
}
2026-02-14 19:30:49 +01:00
// ── Statistics (computed from PG) ────────────────────────────
2026-04-14 23:17:39 +02:00
/// Get deduplication statistics (CDC + legacy).
2026-02-14 19:30:49 +01:00
pub async fn get_stats ( & self ) -> DedupStatsDto {
2026-08-01 16:20:49 +02:00
// Physical storage (all blobs = chunks + legacy).
//
// The `::bigint` casts on the SUM columns are load-bearing:
// Postgres's `SUM(bigint)` returns `numeric` (not bigint), and
// sqlx has no default decode from `numeric` into Rust's `i64`.
// Without the cast, this `query_as` FAILS on a non-empty
// `storage.blobs` table — decode error → the outer
// `unwrap_or((0, 0))` silently swallows it and every operator
// sees `total_blobs = 0, total_bytes_stored = 0` in the admin
// UI while their disk holds gigabytes. On an EMPTY table SUM
// is NULL, COALESCE inlines the literal integer `0`, the row
// decodes fine, and the bug never surfaces during dev — hence
// it lasted so long.
2026-08-01 16:37:23 +02:00
let ( total_blobs , total_bytes_stored ) : ( i64 , i64 ) =
sqlx ::query_as ( "SELECT COUNT(*), COALESCE(SUM(size), 0)::bigint FROM storage.blobs" )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or (( 0 , 0 ));
2026-04-14 23:17:39 +02:00
2026-08-01 16:20:49 +02:00
// Referenced bytes from CDC manifests. Same `numeric`-vs-`bigint`
// gotcha: `SUM(numeric)` → `numeric`; wrap the whole sum in
// `::bigint` so `query_scalar::<_, i64>` decodes cleanly.
2026-04-14 23:17:39 +02:00
let manifest_referenced : i64 = sqlx ::query_scalar (
2026-08-01 16:20:49 +02:00
"SELECT COALESCE(SUM(total_size::BIGINT * ref_count), 0)::bigint FROM storage.chunk_manifests" ,
2026-04-14 23:17:39 +02:00
)
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( 0 );
// Referenced bytes from legacy blobs (those not used as CDC chunks).
// A legacy blob has its hash directly in storage.files.blob_hash.
// We approximate by subtracting manifest-attributed storage.
let all_blob_referenced : i64 = sqlx ::query_scalar (
2026-08-01 16:20:49 +02:00
"SELECT COALESCE(SUM(size::BIGINT * ref_count), 0)::bigint FROM storage.blobs" ,
2026-02-14 19:30:49 +01:00
)
. fetch_one ( self . pool . as_ref ())
. await
2026-04-14 23:17:39 +02:00
. unwrap_or ( 0 );
let manifest_count : i64 =
sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.chunk_manifests" )
. fetch_one ( self . pool . as_ref ())
. await
. unwrap_or ( 0 );
// If manifests exist, use manifest-based referenced bytes;
// otherwise fall back to pure legacy calculation.
let total_bytes_referenced = if manifest_count > 0 {
// Legacy blobs that aren't chunks contribute directly;
// CDC manifests contribute total_size × ref_count.
// Approximation: all_blob_referenced overcounts chunk sharing,
// but manifest_referenced accounts for file-level dedup.
manifest_referenced . max ( all_blob_referenced ) as u64
} else {
all_blob_referenced as u64
};
2026-02-14 19:30:49 +01:00
2026-04-14 23:17:39 +02:00
let total_blobs = total_blobs as u64 ;
let total_bytes_stored = total_bytes_stored as u64 ;
2026-02-14 19:30:49 +01:00
let bytes_saved = total_bytes_referenced . saturating_sub ( total_bytes_stored );
let dedup_ratio = if total_bytes_stored > 0 {
total_bytes_referenced as f64 / total_bytes_stored as f64
} else {
1.0
};
2026-02-14 01:29:34 +01:00
2026-02-14 19:30:49 +01:00
DedupStatsDto {
total_blobs ,
total_bytes_stored ,
total_bytes_referenced ,
bytes_saved ,
2026-04-14 23:17:39 +02:00
dedup_hits : 0 ,
2026-02-14 19:30:49 +01:00
dedup_ratio ,
}
2026-02-14 01:29:34 +01:00
}
2026-02-14 19:30:49 +01:00
// ── Maintenance ──────────────────────────────────────────────
2026-04-14 23:17:39 +02:00
/// Verify integrity of all stored data (manifests + blobs).
2026-02-23 23:43:59 +01:00
///
2026-04-14 23:17:39 +02:00
/// For CDC manifests: verifies chunk count, total_size consistency,
/// and that every referenced chunk exists in the backend.
/// For blobs (chunks + legacy): verifies existence, size, and
/// (for local backends) re-hashes to confirm content integrity.
2026-02-14 19:30:49 +01:00
pub async fn verify_integrity ( & self ) -> Result < Vec < String > , DomainError > {
2026-02-23 23:43:59 +01:00
const VERIFY_CONCURRENCY : usize = 16 ;
2026-07-22 02:06:04 +02:00
const VERIFY_MANIFEST_CONCURRENCY : usize = 8 ;
// Peak temporary memory stays below 256 borrowed keys/results instead
// of scaling with every unique chunk in the store. The independent
// BoxFut gate at 250k unique occurrences measured +112 KiB phase-1
// RSS (+0.4284%) and +80 KiB full-method RSS (+0.3053%), explicitly
// accepted in exchange for the large local/remote latency wins.
const VERIFY_OCCURRENCE_BATCH : usize = 256 ;
2026-04-14 23:17:39 +02:00
let mut issues = Vec ::new ();
2026-02-14 01:29:34 +01:00
2026-04-14 23:17:39 +02:00
// ── Phase 1: Verify CDC manifests ────────────────────────
2026-07-22 02:06:04 +02:00
let manifests : Vec < IntegrityManifest > = sqlx ::query_as (
2026-04-14 23:17:39 +02:00
"SELECT file_hash, chunk_hashes, chunk_sizes, total_size
FROM storage.chunk_manifests" ,
)
. fetch_all ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "List manifests: {} " , e ))) ? ;
2026-07-22 02:06:04 +02:00
// Stores needing at most four probes keep the exact serial fast path:
// the zero-latency A/B gate showed the result map/futures overhead can
// dominate there. Larger stores issue one size probe per DISTINCT chunk in
// each bounded window and overlap at most VERIFY_MANIFEST_CONCURRENCY
// probes.
// Results are then replayed per manifest/occurrence to preserve every
// historical issue message; hashes crossing a window are re-probed.
if integrity_uses_serial_fast_path ( & manifests ) {
// Deliberately retain the original loop shape for the tiny case;
// the independent gate measures this as the unchanged baseline.
for ( file_hash , chunk_hashes , chunk_sizes , total_size ) in & manifests {
let label = & file_hash [ .. file_hash . len (). min ( 12 )];
if chunk_hashes . len () != chunk_sizes . len () {
issues . push ( format! (
"Manifest {label} : chunk_hashes/chunk_sizes length mismatch"
));
continue ;
}
2026-04-14 23:17:39 +02:00
2026-07-22 02:06:04 +02:00
let sum : i64 = chunk_sizes . iter (). sum ();
if sum != * total_size {
issues . push ( format! (
"Manifest {label} : total_size {total_size} != sum of chunk_sizes {sum} "
));
}
2026-04-14 23:17:39 +02:00
2026-07-22 02:06:04 +02:00
for ( i , chunk_hash ) in chunk_hashes . iter (). enumerate () {
let chunk_label = & chunk_hash [ .. chunk_hash . len (). min ( 12 )];
match self . backend . blob_size ( chunk_hash ). await {
Ok ( actual_size ) => {
if actual_size != chunk_sizes [ i ] as u64 {
issues . push ( format! (
"Manifest {label} chunk {chunk_label} : size mismatch \
(expected {} , actual {actual_size} )" ,
chunk_sizes [ i ]
));
}
2026-04-14 23:17:39 +02:00
}
2026-07-22 02:06:04 +02:00
Err ( _ ) => issues . push ( format! (
"Manifest {label} chunk {chunk_label} : missing in backend"
)),
2026-04-14 23:17:39 +02:00
}
2026-07-22 02:06:04 +02:00
}
}
} else if ! manifests . is_empty () {
// Consecutive small manifests share one bounded result table, so
// shared chunks are still probed once per window. A pathological
// single manifest is sliced by occurrence below; neither shape can
// make scratch RAM scale with the complete store.
let mut start = 0 ;
while start < manifests . len () {
let ( _ , chunk_hashes , chunk_sizes , _ ) = & manifests [ start ];
if chunk_hashes . len () == chunk_sizes . len ()
&& chunk_hashes . len () > VERIFY_OCCURRENCE_BATCH
{
let ( file_hash , chunk_hashes , chunk_sizes , total_size ) = & manifests [ start ];
let label = & file_hash [ .. file_hash . len (). min ( 12 )];
let sum : i64 = chunk_sizes . iter (). sum ();
if sum != * total_size {
2026-04-14 23:17:39 +02:00
issues . push ( format! (
2026-07-22 02:06:04 +02:00
"Manifest {label} : total_size {total_size} != sum of chunk_sizes {sum} "
2026-04-14 23:17:39 +02:00
));
}
2026-07-22 02:06:04 +02:00
for offset in ( 0 .. chunk_hashes . len ()). step_by ( VERIFY_OCCURRENCE_BATCH ) {
let end = ( offset + VERIFY_OCCURRENCE_BATCH ). min ( chunk_hashes . len ());
let initial = IntegrityBlobSizes ::new (
chunk_hashes [ offset .. end ]
. iter ()
. map ( String ::as_str )
. collect (),
);
let blob_sizes = populate_integrity_blob_sizes (
self . backend . clone (),
initial ,
VERIFY_MANIFEST_CONCURRENCY ,
)
. await ;
for ( relative , chunk_hash ) in chunk_hashes [ offset .. end ]. iter (). enumerate () {
let i = offset + relative ;
let chunk_label = & chunk_hash [ .. chunk_hash . len (). min ( 12 )];
match blob_sizes . get ( chunk_hash ) {
Some ( actual_size ) => {
if actual_size != chunk_sizes [ i ] as u64 {
issues . push ( format! (
"Manifest {label} chunk {chunk_label} : size mismatch \
(expected {} , actual {actual_size} )" ,
chunk_sizes [ i ]
));
}
}
None => issues . push ( format! (
"Manifest {label} chunk {chunk_label} : missing in backend"
)),
}
}
}
start += 1 ;
continue ;
2026-04-14 23:17:39 +02:00
}
2026-07-22 02:06:04 +02:00
let mut occurrences = 0 ;
let mut end = start ;
while end < manifests . len () {
let ( _ , chunk_hashes , chunk_sizes , _ ) = & manifests [ end ];
let next = if chunk_hashes . len () == chunk_sizes . len () {
chunk_hashes . len ()
} else {
0
};
if next > VERIFY_OCCURRENCE_BATCH
|| ( occurrences > 0 && occurrences + next > VERIFY_OCCURRENCE_BATCH )
{
break ;
}
occurrences += next ;
end += 1 ;
}
debug_assert! ( end > start );
let batch = & manifests [ start .. end ];
let initial = integrity_chunk_sizes ( batch );
let blob_sizes = populate_integrity_blob_sizes (
self . backend . clone (),
initial ,
VERIFY_MANIFEST_CONCURRENCY ,
)
. await ;
issues . extend ( integrity_manifest_issues ( batch , & blob_sizes ));
start = end ;
2026-04-14 23:17:39 +02:00
}
}
// ── Phase 2: Verify blobs (chunks + legacy) ──────────────
2026-02-24 10:45:38 +01:00
let mut row_stream = sqlx ::query_as ::< _ , ( String , i64 ) > (
2026-02-14 19:30:49 +01:00
"SELECT hash, size FROM storage.blobs ORDER BY hash" ,
)
2026-02-24 19:28:00 +01:00
. fetch ( self . maintenance_pool . as_ref ());
2026-02-24 10:45:38 +01:00
let mut total = 0 usize ;
let mut batch = Vec ::with_capacity ( VERIFY_CONCURRENCY );
loop {
let maybe_row = row_stream . try_next (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Failed to list blobs: {} " , e ))
}) ? ;
let is_done = maybe_row . is_none ();
2026-02-14 19:30:49 +01:00
2026-02-24 10:45:38 +01:00
if let Some ( row ) = maybe_row {
total += 1 ;
batch . push ( row );
}
if batch . len () >= VERIFY_CONCURRENCY || ( is_done && ! batch . is_empty ()) {
2026-04-14 21:33:38 +02:00
let backend = self . backend . clone ();
2026-02-24 10:45:38 +01:00
let current_batch =
std ::mem ::replace ( & mut batch , Vec ::with_capacity ( VERIFY_CONCURRENCY ));
2026-04-14 23:17:39 +02:00
let blob_issues : Vec < String > = stream ::iter ( current_batch )
2026-02-24 10:45:38 +01:00
. map ( move | ( hash , expected_size ) | {
2026-04-14 21:33:38 +02:00
let backend = backend . clone ();
2026-02-24 10:45:38 +01:00
async move {
let mut issues = Vec ::new ();
2026-04-14 21:33:38 +02:00
match backend . blob_size ( & hash ). await {
Ok ( actual_size ) => {
if actual_size != expected_size as u64 {
issues . push ( format! (
" {} : size mismatch (expected: {} , actual: {} )" ,
hash , expected_size , actual_size ,
));
}
2026-02-24 13:06:40 +01:00
}
2026-04-14 21:33:38 +02:00
Err ( _ ) => {
issues . push ( format! ( " {} : blob missing in backend" , hash ));
2026-02-24 13:06:40 +01:00
return issues ;
}
};
2026-04-14 21:33:38 +02:00
if let Some ( blob_path ) = backend . local_blob_path ( & hash ) {
match Self ::hash_file ( & blob_path ). await {
Ok ( actual_hash ) => {
if actual_hash != hash {
issues . push ( format! (
" {} : hash mismatch (actual: {} )" ,
hash , actual_hash ,
));
}
}
Err ( e ) => {
issues . push ( format! ( " {} : read error ( {} )" , hash , e ));
2026-02-24 10:45:38 +01:00
}
}
}
issues
2026-02-23 23:43:59 +01:00
}
2026-02-24 10:45:38 +01:00
})
. buffer_unordered ( VERIFY_CONCURRENCY )
. flat_map ( stream ::iter )
. collect ()
. await ;
2026-04-14 23:17:39 +02:00
issues . extend ( blob_issues );
2026-02-24 10:45:38 +01:00
}
if is_done {
break ;
}
}
2026-02-14 01:29:34 +01:00
2026-04-14 23:17:39 +02:00
if issues . is_empty () {
tracing ::info! (
"Integrity check passed ({} manifests, {} blobs)" ,
manifests . len (),
total
);
2026-02-14 01:29:34 +01:00
} else {
2026-04-14 23:17:39 +02:00
tracing ::warn! ( "Integrity check found {} issues" , issues . len ());
2026-02-14 01:29:34 +01:00
}
2026-04-14 23:17:39 +02:00
Ok ( issues )
2026-02-14 01:29:34 +01:00
}
2026-04-14 23:17:39 +02:00
/// Garbage collect orphaned manifests and blobs.
2026-02-25 23:54:09 +01:00
///
2026-06-19 09:37:51 +00:00
/// Phase 1: Delete manifests with ref_count = 0 (or no referencing file),
/// then decrement chunk ref_counts for their chunks.
/// Phase 2: Delete blobs (chunks + legacy) that are unreferenced
/// (ref_count = 0), no longer listed by any manifest or file, and have
/// been orphaned for at least [`GC_ORPHAN_GRACE_SECS`](Self::GC_ORPHAN_GRACE_SECS).
/// The grace window and reference cross-checks together make the sweep safe
/// against a concurrent uploader re-referencing a just-orphaned chunk.
2026-02-14 19:30:49 +01:00
pub async fn garbage_collect ( & self ) -> Result < ( u64 , u64 ), DomainError > {
2026-06-24 23:10:26 +02:00
self . garbage_collect_with_grace ( Self ::GC_ORPHAN_GRACE_SECS )
. await
}
2026-07-27 23:40:40 +02:00
/// Test-only variant that bypasses the orphan grace window — used
/// by `POST /api/admin/jobs/dedup_gc/trigger?force=true` (via the
/// `JobRunArgs.force` dispatch in `JobHandler::run`) so the
2026-06-24 23:10:26 +02:00
/// integration suite can reap just-orphaned blobs synchronously
/// (waiting out the production 1 h grace inside a test run is a
/// non-starter). Drops the same rows the regular sweep would, just
/// without the time floor. Unsafe under concurrent uploads because
/// it reopens the TOCTOU window the grace closes — only the
2026-07-27 23:40:40 +02:00
/// admin-triggered `?force=true` path reaches here.
2026-06-24 23:10:26 +02:00
pub async fn garbage_collect_force ( & self ) -> Result < ( u64 , u64 ), DomainError > {
self . garbage_collect_with_grace ( 0 ). await
}
2026-06-25 00:07:16 +02:00
async fn garbage_collect_with_grace ( & self , grace_secs : i64 ) -> Result < ( u64 , u64 ), DomainError > {
2026-02-25 23:54:09 +01:00
const BATCH_SIZE : i64 = 500 ;
2026-02-14 01:29:34 +01:00
2026-02-25 23:54:09 +01:00
let mut total_deleted = 0 u64 ;
let mut total_bytes = 0 u64 ;
2026-02-14 01:29:34 +01:00
2026-04-14 23:17:39 +02:00
// ── Phase 1: GC orphaned manifests ───────────────────────
2026-09-04 12:30:12 +02:00
// A manifest is collectible when NO registered reference source
// references its file_hash. That single condition covers both
// delete paths: the single-file service path removes the
// storage.files row, and so do the bulk paths (user cascade,
// empty_trash) — whichever decrements ref_count along the way is
// irrelevant here.
2026-08-23 15:52:01 +02:00
//
2026-09-04 12:30:12 +02:00
// ref_count is deliberately NOT part of this. It used to be, as
// `ref_count <= 0 OR <unreferenced>`, which meant a counter that
// under-reported deleted live content without ever consulting the
// registry that knew better. See `manifest_reap_sql` for the full
// reasoning and for what moved to the refcount recompute instead.
2026-04-14 23:17:39 +02:00
loop {
2026-07-22 02:06:04 +02:00
// Keep the historically cheap DELETE-only shape for the dominant
// no-work sweep. Embedding it in the delete/aggregate/update CTE
// made an all-live batch 15-45% slower despite issuing the same one
// statement. With one returned manifest, retain the exact serial
// update. From two onward, aggregate in-process and issue one UPDATE:
// the measured crossover is already positive at two, while 500 and
// 1,000 manifests improve by 60.03x and 51.16x respectively.
2026-08-23 15:52:01 +02:00
// Assembled once at construction (see `manifest_reap_sql`), not
// per sweep: no string work in the hot path, a stable statement for
// prepared-statement caching, and a byte-for-byte golden test.
let batch : Vec < ( String , Vec < String > , i64 ) > = sqlx ::query_as ( & self . manifest_reap_sql )
. bind ( BATCH_SIZE )
. fetch_all ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "GC manifests: {e} " ))) ? ;
2026-04-14 23:17:39 +02:00
if batch . is_empty () {
break ;
}
2026-07-22 02:06:04 +02:00
// The DELETE above commits independently of the refcount UPDATE.
// Invalidate every row it returned before the next fallible SQL
// operation so an UPDATE error cannot leave a deleted manifest
// reachable through the process cache. Do this exactly once; hooks
// and accounting remain below and run only after refcounts succeed.
for ( file_hash , _ , _ ) in & batch {
2026-07-16 14:20:20 +00:00
self . manifest_cache . invalidate ( file_hash ). await ;
2026-08-28 08:58:11 +02:00
// Drop everything derived FROM this Blob, exactly as
// `reap_blob` does for the single-blob path.
//
// Without this, bulk manifest reaping orphans the rows: the
// reap predicate protects a manifest that IS a derived
// artifact (`content_derived_blobs.blob_hash`), but
// deliberately not one that is the SOURCE of them — counting
// `source_hash` as a reference would pin every original for
// as long as a thumbnail existed. So the source is reaped
// correctly, and the purge has to follow it.
//
// It did not, and the leak is permanent rather than cosmetic:
// the orphaned row holds `chunk_manifests.ref_count` at 1 on
// the thumbnail's own blob, so GC is thereafter *correct* to
// refuse it and those bytes are never reclaimed. Every
// deleted image left three of them behind — one per size.
//
// Found by storage_cleanup_check.sh: three leftover blobs,
// all `derived=1`, all naming one `src` whose manifest, blob
// row and files were already gone.
self . purge_derived_blobs ( file_hash ). await ;
2026-07-22 02:06:04 +02:00
}
if batch . len () == 1 {
2026-04-14 23:17:39 +02:00
sqlx ::query (
2026-05-21 21:12:38 +02:00
"UPDATE storage.blobs
2026-07-22 02:06:04 +02:00
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE
WHEN GREATEST(ref_count - 1, 0) = 0 THEN now()
ELSE orphaned_at
END
2026-05-21 21:12:38 +02:00
WHERE hash = ANY($1)" ,
2026-04-14 23:17:39 +02:00
)
2026-07-22 02:06:04 +02:00
. bind ( & batch [ 0 ]. 1 )
2026-04-14 23:17:39 +02:00
. execute ( self . maintenance_pool . as_ref ())
. await
2026-07-22 02:06:04 +02:00
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "GC chunk refs: {e} " ))) ? ;
} else {
// One reference is owned per DISTINCT chunk hash per manifest,
// even if that chunk occurs multiple times in the file. Borrow
// hashes while aggregating so shared chunks are cloned only once.
let mut decrements = HashMap ::<& str , i32 > ::new ();
for ( _ , chunk_hashes , _ ) in & batch {
let distinct : HashSet <& str > = chunk_hashes . iter (). map ( String ::as_str ). collect ();
for hash in distinct {
* decrements . entry ( hash ). or_default () += 1 ;
}
}
let ( hashes , decrement_by ) : ( Vec < String > , Vec < i32 > ) = decrements
. into_iter ()
. map ( | ( hash , decrement ) | ( hash . to_owned (), decrement ))
. unzip ();
2026-04-14 23:17:39 +02:00
2026-07-22 02:06:04 +02:00
sqlx ::query (
"UPDATE storage.blobs b
SET ref_count = GREATEST(b.ref_count - d.decrement_by, 0),
orphaned_at = CASE
WHEN GREATEST(b.ref_count - d.decrement_by, 0) = 0
THEN now()
ELSE b.orphaned_at
END
FROM unnest($1::text[], $2::integer[]) AS d(hash, decrement_by)
WHERE b.hash = d.hash" ,
)
. bind ( & hashes )
. bind ( & decrement_by )
. execute ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "GC chunk refs: {e} " ))) ? ;
}
for ( file_hash , chunk_hashes , size ) in & batch {
2026-06-24 23:51:22 +02:00
// Fire the blob hooks against the **manifest's file_hash** —
// that's the key thumbnails are stored under (whole-file
// BLAKE3, not chunk hashes). Phase 2 below fires hooks for
// individual chunk hashes only; without this call, a
// CDC-chunked file's thumbnails leak on disk because the
// chunk-keyed hook never finds them. Symptom: orphan webp
// under `.thumbnails/{icon,preview,large}/<file_hash>.webp`
// after a user-cascade-delete of a video upload.
2026-08-24 00:53:46 +02:00
self . reap_blob ( file_hash ). await ;
2026-06-24 23:51:22 +02:00
2026-04-14 23:17:39 +02:00
total_bytes += * size as u64 ;
tracing ::debug! (
"GC: removed manifest {} ({} chunks)" ,
& file_hash [ .. file_hash . len (). min ( 12 )],
chunk_hashes . len ()
);
}
total_deleted += batch . len () as u64 ;
tokio ::task ::yield_now (). await ;
}
// ── Phase 2: GC orphaned blobs/chunks ────────────────────
2026-06-19 09:37:51 +00:00
// A blob row is collectible only when ALL of these hold:
// • ref_count <= 0, AND
// • it has been orphaned for at least GC_ORPHAN_GRACE_SECS (or has a
// NULL orphaned_at — a pre-migration row or a path that never
// stamped it; those are safe to take immediately), AND
// • no manifest still lists it as a chunk, AND
2026-09-04 21:43:49 +02:00
// • no file still points at it directly (legacy whole-file blob),
// AND
// • no registered reference source claims it at the chunk level.
2026-06-19 09:37:51 +00:00
//
2026-09-04 21:43:49 +02:00
// The NOT EXISTS guards mean a stale ref_count = 0 on still-referenced
// content can only delay collection, never delete live bytes — unlike
// Phase 1 before `manifest_reap_sql` dropped its ref_count arm, this
// phase always had that property. The registry conjunct is additive
// (see `blob_reap_sql`): it cannot reap anything the hardcoded guards
// would have spared, it just stops a future chunk-level source from
// being missed. The grace window keeps a
2026-06-19 09:37:51 +00:00
// concurrent uploader that is about to pin a just-orphaned chunk from
// racing the row-delete → file-unlink gap (see GC_ORPHAN_GRACE_SECS).
// The ctid snapshot already protects against a pin that commits DURING
// the DELETE (the pin rewrites the row's ctid, so it drops out of the
// set); grace covers the remaining post-commit unlink window.
2026-02-25 23:54:09 +01:00
loop {
2026-09-04 21:43:49 +02:00
let batch : Vec < ( String , i64 ) > = sqlx ::query_as ( & self . blob_reap_sql )
. bind ( BATCH_SIZE )
. bind ( grace_secs as i32 )
. fetch_all ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "GC blobs: {e} " ))) ? ;
2026-02-25 23:54:09 +01:00
if batch . is_empty () {
break ;
}
2026-06-19 09:37:51 +00:00
let n = batch . len ();
2026-02-25 23:54:09 +01:00
2026-06-19 09:37:51 +00:00
// The rows are already gone, so a concurrent re-upload of identical
// content recreates both row and file (durability before
// visibility); the grace window above keeps that race vanishingly
// narrow. Unlink the backing files with bounded fan-out so a large
// sweep doesn't serialise on a slow (e.g. S3) backend.
let backend = self . backend . clone ();
let deleted : Vec < ( String , i64 ) > = stream ::iter ( batch )
. map ( | ( hash , size ) | {
let backend = backend . clone ();
async move {
if let Err ( e ) = backend . delete_blob ( & hash ). await {
tracing ::warn! ( "Failed to delete orphan blob {hash}: {e}" );
}
( hash , size )
}
})
. buffer_unordered ( Self ::CHUNK_UPLOAD_CONCURRENCY )
. collect ()
. await ;
for ( hash , size ) in & deleted {
2026-08-24 00:53:46 +02:00
self . reap_blob ( hash ). await ;
2026-02-25 23:54:09 +01:00
total_bytes += * size as u64 ;
2026-02-14 01:29:34 +01:00
}
2026-06-19 09:37:51 +00:00
total_deleted += n as u64 ;
2026-02-25 23:54:09 +01:00
tokio ::task ::yield_now (). await ;
2026-02-14 01:29:34 +01:00
}
2026-02-25 23:54:09 +01:00
if total_deleted > 0 {
2026-04-14 23:17:39 +02:00
tracing ::info! ( "GC: removed {total_deleted} items ({total_bytes} bytes)" );
2026-02-14 01:29:34 +01:00
}
2026-02-25 23:54:09 +01:00
Ok (( total_deleted , total_bytes ))
2026-02-14 01:29:34 +01:00
}
2026-06-11 10:43:45 +00:00
// ── Legacy whole-file blob re-chunk migration ────────────────
//
// Files uploaded before CDC chunking landed (migration
// 20260414000000_chunk_manifests) are stored as ONE whole-file blob with
// no manifest. Every legacy fallback in this service exists to serve
// them — and with encryption enabled, a Range read of one decrypts the
// ENTIRE blob (AES-GCM is all-or-nothing per blob).
//
// This migration converts each legacy blob into a regular CDC file:
// after it, the converted file is indistinguishable from a native CDC
// upload, every read takes the chunked path, and the legacy fallbacks
// go permanently cold (they remain as the safety net while a deployment
// is mid-migration; they can be deleted from the codebase once fleets
// report `legacy re-chunk: nothing to do`).
//
// Per-hash algorithm:
2026-06-11 13:06:33 +00:00
// 1. Stream the blob through the normal read path (this decrypts it
// when encryption is on) straight into the chunk-ingest engine —
// no spool file — verifying BLAKE3 == hash before keeping the
// chunks (each distinct chunk bumped once — the manifest's
// reference).
// 2. One short accounting TX with the blob row locked:
2026-06-11 10:43:45 +00:00
// manifest INSERT with ref_count = N (current file rows referencing
// the hash), blob ref_count -= N (those references now live on the
// manifest), DELETE the blob row only if it hits exactly 0.
2026-06-11 13:06:33 +00:00
// 3. Physically delete the whole-file blob only when its row was
2026-06-11 10:43:45 +00:00
// removed. Single-chunk files (chunk hash == file hash) keep the
// physical blob — it IS the chunk; only the bookkeeping moves.
//
// Concurrency: the row lock serializes against the file-delete trigger
// and the legacy dedup-hit path. A racing identical upload can land one
// legacy reference after our commit; the blob row then survives (> 0)
// and that file stays readable through the legacy fallback — a bounded
2026-06-11 13:06:33 +00:00
// space leak, never data loss. A crash between step 1 and 2 leaks one
2026-06-11 10:43:45 +00:00
// +1 on that file's chunk refs (re-run re-bumps); also a bounded leak,
// never data loss.
/// Count legacy whole-file blobs still referenced by at least one file
/// row (the migration's work queue). Runs on the maintenance pool.
pub async fn count_legacy_blobs ( & self ) -> Result < i64 , DomainError > {
sqlx ::query_scalar (
"SELECT COUNT(*) FROM storage.blobs b
WHERE NOT EXISTS (SELECT 1 FROM storage.chunk_manifests m
WHERE m.file_hash = b.hash)
AND EXISTS (SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash)" ,
)
. fetch_one ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Count legacy blobs: {e} " )))
}
/// Spawn the legacy re-chunk migration as a background task.
///
/// Zero-cost when no legacy blobs exist (one COUNT query, debug log).
/// Called from the composition root after `initialize()`.
pub fn spawn_legacy_rechunk ( self : & Arc < Self > ) {
let svc = Arc ::clone ( self );
tokio ::spawn ( async move {
match svc . count_legacy_blobs (). await {
Ok ( 0 ) => {
tracing ::debug! ( "Legacy re-chunk: no legacy whole-file blobs — nothing to do" );
}
Ok ( n ) => {
tracing ::info! (
"Legacy re-chunk: {n} pre-CDC whole-file blob(s) referenced by files — \
starting background migration (maintenance pool)"
);
match svc . rechunk_legacy_blobs (). await {
Ok ( report ) => tracing ::info! (
migrated = report . migrated ,
failed = report . failed ,
freed_bytes = report . freed_bytes ,
"Legacy re-chunk complete: {} blob(s) converted to CDC manifests, \
{} failed (left untouched), {} bytes of whole-file blobs freed" ,
report . migrated ,
report . failed ,
report . freed_bytes ,
),
Err ( e ) => tracing ::error! ( "Legacy re-chunk aborted: {e}" ),
}
}
Err ( e ) => tracing ::error! ( "Legacy re-chunk: startup count failed: {e}" ),
}
});
}
/// Convert every legacy whole-file blob into CDC chunks + manifest.
///
/// Incremental and resumable: a manifest row is the per-hash "done"
/// marker, so re-running after a crash continues where it left off.
/// Per-hash failures (e.g. a corrupt blob that no longer matches its
/// hash) are logged, counted, and skipped — they never block the sweep.
pub async fn rechunk_legacy_blobs ( & self ) -> Result < LegacyRechunkReport , DomainError > {
const BATCH_SIZE : i64 = 64 ;
/// Hard cap on per-hash failures before aborting the sweep — if
/// this many blobs are corrupt something is systemically wrong and
/// an operator should look before we touch anything else.
const MAX_FAILURES : usize = 1_000 ;
let mut report = LegacyRechunkReport ::default ();
// Failed hashes are excluded from the candidate query so a corrupt
// blob cannot make the sweep loop forever.
let mut failed_hashes : Vec < String > = Vec ::new ();
loop {
let batch : Vec < ( String , Option < String > ) > = sqlx ::query_as (
"SELECT b.hash, b.content_type FROM storage.blobs b
WHERE NOT EXISTS (SELECT 1 FROM storage.chunk_manifests m
WHERE m.file_hash = b.hash)
AND EXISTS (SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash)
AND NOT (b.hash = ANY($2))
ORDER BY b.hash
LIMIT $1" ,
)
. bind ( BATCH_SIZE )
. bind ( & failed_hashes )
. fetch_all ( self . maintenance_pool . as_ref ())
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Legacy candidate query: {e} " ))
}) ? ;
if batch . is_empty () {
break ;
}
for ( hash , content_type ) in batch {
match self . rechunk_one_legacy_blob ( & hash , content_type ). await {
Ok ( freed ) => {
report . migrated += 1 ;
report . freed_bytes += freed ;
if report . migrated % 50 == 0 {
tracing ::info! (
"Legacy re-chunk progress: {} migrated, {} failed" ,
report . migrated ,
report . failed
);
}
}
Err ( e ) => {
report . failed += 1 ;
tracing ::error! (
"Legacy re-chunk: blob {} failed (left untouched): {e}" ,
& hash [ .. hash . len (). min ( 12 )],
);
failed_hashes . push ( hash );
if failed_hashes . len () >= MAX_FAILURES {
return Err ( DomainError ::internal_error (
"Dedup" ,
format! (
"Legacy re-chunk: aborting after {MAX_FAILURES} per-blob \
failures — inspect blob storage integrity"
),
));
}
}
}
tokio ::task ::yield_now (). await ;
}
}
Ok ( report )
}
/// Migrate a single legacy whole-file blob. Returns the number of
/// physical bytes freed (0 when the blob doubles as its own chunk).
async fn rechunk_one_legacy_blob (
& self ,
hash : & str ,
content_type : Option < String > ,
) -> Result < u64 , DomainError > {
2026-06-11 13:06:33 +00:00
// ── 1. Stream + verify (decrypts via the normal read path) ──
// The chunk store is fed directly from the blob read stream — no
// spool file. Sizes come from the CDC pass over the hash-verified
// plaintext; `storage.blobs.size` is legacy metadata we don't trust
2026-06-11 10:43:45 +00:00
// for the manifest's Range arithmetic.
2026-06-11 13:06:33 +00:00
let ( chunk_hashes , chunk_sizes ) = self . ingest_legacy_blob ( hash ). await ? ;
2026-06-11 10:43:45 +00:00
let total_size : u64 = chunk_sizes . iter (). sum ();
// ── 2. Accounting TX: move the file references onto the manifest ──
let mut tx =
self . maintenance_pool . begin (). await . map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Rechunk TX begin: {e} " ))
}) ? ;
// Lock the legacy blob row — serializes against the file-delete
// trigger and the legacy dedup-hit path for this hash.
let blob_row_exists = sqlx ::query_scalar ::< _ , i32 > (
"SELECT ref_count FROM storage.blobs WHERE hash = $1 FOR UPDATE" ,
)
. bind ( hash )
. fetch_optional ( & mut * tx )
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Rechunk lock blob: {e} " ))) ?
. is_some ();
let file_refs : i64 =
sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.files WHERE blob_hash = $1" )
. bind ( hash )
. fetch_one ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Rechunk count refs: {e} " ))
}) ? ;
// ref_count = N file references; if every reference vanished while
// we were spooling, the zero-ref manifest is swept by the existing
// GC (which also unwinds the chunk refs taken in store_chunks).
let inserted = sqlx ::query (
"INSERT INTO storage.chunk_manifests
(file_hash, chunk_hashes, chunk_sizes, total_size, chunk_count,
content_type, ref_count)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (file_hash) DO NOTHING" ,
)
. bind ( hash )
. bind ( & chunk_hashes )
. bind ( chunk_sizes . iter (). map ( | s | * s as i64 ). collect ::< Vec < _ >> ())
. bind ( total_size as i64 )
. bind ( chunk_hashes . len () as i32 )
. bind ( & content_type )
. bind ( file_refs as i32 )
. execute ( & mut * tx )
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Rechunk manifest: {e} " ))) ?
. rows_affected ();
if inserted == 0 {
// A manifest appeared concurrently — only possible if the same
2026-06-11 13:06:33 +00:00
// content was re-uploaded and fully stored while we streamed.
2026-06-11 10:43:45 +00:00
// Their bookkeeping is already correct; drop ours.
tx . rollback (). await . ok ();
2026-06-11 13:06:33 +00:00
self . release_chunk_refs ( self . maintenance_pool . as_ref (), & chunk_hashes )
. await ;
2026-06-11 10:43:45 +00:00
return Ok ( 0 );
}
// The N file references now live on the manifest; remove them from
// the legacy blob and drop its row only when nothing else (other
// manifests using this blob as a chunk, racing legacy references)
// still points at it.
let mut blob_row_deleted = false ;
if blob_row_exists {
sqlx ::query (
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - $2, 0)
WHERE hash = $1" ,
)
. bind ( hash )
. bind ( file_refs as i32 )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Rechunk deref blob: {e} " ))
}) ? ;
blob_row_deleted =
sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = $1 AND ref_count = 0" )
. bind ( hash )
. execute ( & mut * tx )
. await
. map_err ( | e | {
DomainError ::internal_error ( "Dedup" , format! ( "Rechunk drop blob: {e} " ))
}) ?
. rows_affected ()
> 0 ;
}
tx . commit ()
. await
. map_err ( | e | DomainError ::internal_error ( "Dedup" , format! ( "Rechunk commit: {e} " ))) ? ;
// ── 3. Physical cleanup (after commit) ──
// Deleted row ⇒ the hash is not one of its own chunks (a single-chunk
// file keeps ref_count ≥ 1 from the manifest), but guard anyway.
let mut freed = 0 ;
if blob_row_deleted && ! chunk_hashes . iter (). any ( | c | c == hash ) {
match self . backend . delete_blob ( hash ). await {
Ok (()) => freed = total_size ,
Err ( e ) => tracing ::warn! (
"Legacy re-chunk: converted {} but failed to delete the \
old whole-file blob (GC will not retry — row is gone): {e}" ,
& hash [ .. hash . len (). min ( 12 )],
),
}
}
tracing ::debug! (
"Legacy re-chunk: {} → {} chunk(s), {} file ref(s) moved to manifest{}" ,
& hash [ .. hash . len (). min ( 12 )],
chunk_hashes . len (),
file_refs ,
if blob_row_deleted {
", whole-file blob freed"
} else {
""
},
);
Ok ( freed )
}
2026-06-11 13:06:33 +00:00
/// Re-chunk one legacy whole-file blob straight from the backend read
/// stream (no spool file), verifying that the streamed content still
/// matches its recorded BLAKE3 before the chunks are kept.
///
/// On mismatch the freshly taken chunk references are released — the
/// written chunk bytes become unreferenced rows the GC sweeps — and an
/// error is returned; the legacy blob itself stays untouched.
async fn ingest_legacy_blob ( & self , hash : & str ) -> Result < ( Vec < String > , Vec < u64 > ), DomainError > {
let stream = self . read_blob_stream ( hash ). await ? ;
let outcome = self . ingest_chunks_from_stream ( stream ). await ? ;
if outcome . file_hash != hash {
let distinct = outcome . distinct_hashes ();
self . release_chunk_refs ( self . maintenance_pool . as_ref (), & distinct )
. await ;
2026-06-11 10:43:45 +00:00
return Err ( DomainError ::internal_error (
"Dedup" ,
2026-06-11 13:06:33 +00:00
format! (
"Blob content does not match its hash (expected {hash} , got {} )" ,
outcome . file_hash
),
2026-06-11 10:43:45 +00:00
));
}
2026-06-11 13:06:33 +00:00
Ok (( outcome . chunk_hashes , outcome . chunk_sizes ))
2026-06-11 10:43:45 +00:00
}
2026-06-11 13:06:33 +00:00
/// Best-effort compensation: drop one reference per *distinct* chunk
/// hash (clamped at 0). Used whenever an ingest session's references end
/// up not being attached to a manifest — dedup hit, lost insert race, or
/// content-verification failure.
async fn release_chunk_refs ( & self , pool : & PgPool , chunk_hashes : & [ String ]) {
2026-06-11 10:43:45 +00:00
if chunk_hashes . is_empty () {
return ;
}
if let Err ( e ) = sqlx ::query (
2026-06-19 09:37:51 +00:00
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
2026-06-11 10:43:45 +00:00
WHERE hash = ANY($1)" ,
)
. bind ( chunk_hashes )
2026-06-11 13:06:33 +00:00
. execute ( pool )
2026-06-11 10:43:45 +00:00
. await
{
2026-06-11 13:06:33 +00:00
tracing ::warn! ( "Dedup: failed to release chunk refs: {e}" );
2026-06-11 10:43:45 +00:00
}
}
}
/// Outcome of a [`DedupService::rechunk_legacy_blobs`] sweep.
#[derive(Debug, Default, Clone, Copy)]
pub struct LegacyRechunkReport {
/// Legacy blobs successfully converted to CDC manifests.
pub migrated : u64 ,
/// Blobs that failed (corrupt / unreadable) and were left untouched.
pub failed : u64 ,
/// Physical bytes of whole-file blobs deleted after conversion.
pub freed_bytes : u64 ,
2026-02-14 01:29:34 +01:00
}
// ─── Port implementation ─────────────────────────────────────────────────────
impl DedupPort for DedupService {
async fn blob_exists ( & self , hash : & str ) -> bool {
self . blob_exists ( hash ). await
}
2026-08-23 23:10:00 +02:00
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
}
2026-02-14 01:29:34 +01:00
async fn get_blob_metadata ( & self , hash : & str ) -> Option < BlobMetadataDto > {
2026-02-14 19:30:49 +01:00
self . get_blob_metadata ( hash ). await
2026-02-14 01:29:34 +01:00
}
2026-02-15 17:53:25 +01:00
async fn read_blob_stream (
& self ,
hash : & str ,
) -> Result < Pin < Box < dyn Stream < Item = Result < Bytes , std ::io ::Error >> + Send >> , DomainError >
{
self . read_blob_stream ( hash ). await
}
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 >
{
self . read_blob_range_stream ( hash , start , end ). await
}
async fn blob_size ( & self , hash : & str ) -> Result < u64 , DomainError > {
self . blob_size ( hash ). await
}
2026-02-14 01:29:34 +01:00
async fn add_reference ( & self , hash : & str ) -> Result < (), DomainError > {
2026-02-14 19:30:49 +01:00
self . add_reference ( hash ). await
2026-02-14 01:29:34 +01:00
}
async fn remove_reference ( & self , hash : & str ) -> Result < bool , DomainError > {
2026-02-14 19:30:49 +01:00
self . remove_reference ( hash ). await
2026-02-14 01:29:34 +01:00
}
async fn hash_file ( & self , path : & Path ) -> Result < String , DomainError > {
DedupService ::hash_file ( path )
. await
. map_err ( DomainError ::from )
}
2026-03-04 14:02:15 +01:00
fn blob_path ( & self , hash : & str ) -> PathBuf {
self . blob_path ( hash )
}
2026-02-14 01:29:34 +01:00
async fn get_stats ( & self ) -> DedupStatsDto {
2026-02-14 19:30:49 +01:00
self . get_stats (). await
2026-02-14 01:29:34 +01:00
}
async fn flush ( & self ) -> Result < (), DomainError > {
2026-02-14 19:30:49 +01:00
// No-op: PostgreSQL handles persistence automatically via WAL/commit
Ok (())
2026-02-14 01:29:34 +01:00
}
async fn verify_integrity ( & self ) -> Result < Vec < String > , DomainError > {
2026-02-14 19:30:49 +01:00
self . verify_integrity (). await
2026-02-14 01:29:34 +01:00
}
}
2026-04-14 23:17:39 +02:00
2026-07-27 22:56:02 +02:00
// ─── JobRegistry integration ────────────────────────────────────────────────
/// Registered name for the dedup GC job. Stable identifier used in
/// log lines, `admin.background_runs.job_name` (when Part 2 lands),
2026-07-27 23:23:23 +02:00
/// and admin URLs (`POST /api/admin/jobs/dedup_gc/trigger`).
2026-07-27 22:56:02 +02:00
pub const DEDUP_GC_JOB_NAME : & str = "dedup_gc" ;
2026-07-28 21:13:09 +02:00
impl DedupService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. **On-demand only** —
/// registered with `interval = None`. The periodic GC role
/// belongs to trash cleanup (which invokes `garbage_collect()`
/// inline as its tail step); a duplicate scheduled tick here
/// would double the reclamation work. Registration exists solely
/// to expose the admin trigger uniformly.
pub async fn register (
self : std ::sync ::Arc < Self > ,
registry : & crate ::infrastructure ::scheduler ::JobRegistry ,
) -> std ::sync ::Arc < Self > {
registry
. register (
self . clone () as std ::sync ::Arc < dyn crate ::infrastructure ::scheduler ::JobHandler > ,
None , // on-demand
None , // no timeout
)
. await ;
self
}
}
2026-07-27 22:56:02 +02:00
#[async_trait::async_trait]
impl crate ::infrastructure ::scheduler ::JobHandler for DedupService {
fn name ( & self ) -> & str {
DEDUP_GC_JOB_NAME
}
2026-08-29 00:04:12 +02:00
fn description ( & self ) -> & 'static str {
"Reclaims blobs and chunk manifests that no file, thumbnail or \
preview references any more, once they are past the orphan grace \
window. Trash cleanup already runs this as its tail step; \
triggering it here is for reclaiming immediately rather than at \
the next tick. Add ?force=true to skip the grace window."
}
/// Deletes bytes. `force` is its accelerator, not a repair flag —
/// there is nothing this job reports without also acting on it.
fn mutates ( & self ) -> crate ::infrastructure ::scheduler ::Mutates {
crate ::infrastructure ::scheduler ::Mutates ::Always
}
2026-07-27 22:56:02 +02:00
/// Runs one `garbage_collect` sweep — the same reclamation that
/// `TrashCleanupService` invokes inline as its tail step, exposed
/// through the scheduler so operators can trigger it uniformly via
2026-07-27 23:23:23 +02:00
/// `POST /api/admin/jobs/dedup_gc/trigger`.
2026-07-27 22:56:02 +02:00
///
/// Registered with `interval = None` (on-demand only): the periodic
/// tick belongs to trash cleanup, whose sweep already runs GC as
/// its final phase. Registering a redundant periodic tick here
/// would double the reclamation work with no benefit; the admin
/// trigger is what the registered entry buys us — uniform log lines,
/// panic containment, exclusivity vs. any concurrent trigger.
///
/// `count` reports blobs reclaimed; `extra.bytes_reclaimed` reports
/// the freed disk. GC returning `(0, 0)` is normal — it means trash
/// cleanup already reaped everything.
2026-07-27 23:23:23 +02:00
///
2026-09-07 12:30:40 +02:00
/// `force = true` skips the orphan grace window
2026-07-27 23:40:40 +02:00
/// (`garbage_collect_force` — grace_secs = 0). Same semantic as
/// `POST /api/admin/jobs/dedup_gc/trigger?force=true`. Unsafe
2026-07-27 23:23:23 +02:00
/// under concurrent uploads: only reachable through the admin
/// endpoint and only intentionally used by tests + operator
/// diagnostic sessions.
2026-09-07 12:30:40 +02:00
fn parameters ( & self ) -> & 'static [ crate ::infrastructure ::scheduler ::JobParam ] {
use crate ::infrastructure ::scheduler ::JobParam ;
// A named `const` rather than a bare `&[…]` literal: implicit
// const promotion does not cover `const fn` calls, so the
// literal would be a temporary. Same shape in every job.
const PARAMS : & [ JobParam ] = & [ JobParam ::boolean (
"force" ,
false ,
"Skip the orphan grace window. Unsafe under concurrent \
uploads — it reopens the TOCTOU window the grace closes." ,
)];
PARAMS
}
2026-07-27 23:23:23 +02:00
async fn run (
& self ,
args : & crate ::infrastructure ::scheduler ::JobRunArgs ,
) -> crate ::infrastructure ::scheduler ::JobOutcome {
2026-07-27 22:56:02 +02:00
use crate ::infrastructure ::scheduler ::JobOutcome ;
2026-09-07 12:30:40 +02:00
let force = args . get_bool ( "force" );
let result = if force {
2026-07-27 23:23:23 +02:00
self . garbage_collect_force (). await
} else {
self . garbage_collect (). await
};
match result {
Ok (( items , bytes )) => JobOutcome ::ok_with (
items ,
2026-09-07 12:30:40 +02:00
serde_json ::json! ({ "bytes_reclaimed" : bytes , "forced" : force }),
2026-07-27 23:23:23 +02:00
),
2026-07-27 23:57:43 +02:00
Err ( e ) => JobOutcome ::err ( format! ( "dedup GC failed: {e} " )),
2026-07-27 22:56:02 +02:00
}
}
}
2026-04-14 23:17:39 +02:00
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super ::* ;
2026-08-23 15:52:01 +02:00
/// Golden test for the statement `garbage_collect` runs against production
/// data. It is assembled from the registered reference sources rather than
/// written as a literal, so this pins the whole thing byte-for-byte — the
/// point being that a reviewer reads the SQL *here* instead of mentally
/// evaluating the registry.
///
/// If this fails after adding a source, read the diff carefully: the new
/// branch must appear inside the `NOT (...)` group, ORed with the others.
/// A branch landing outside that group inverts the predicate for every
/// other source and reaps live manifests.
2026-09-04 12:30:12 +02:00
///
/// **`ref_count` must not reappear in this statement.** It used to be
/// there as `ref_count <= 0 OR NOT (…)`, which let a counter that
/// under-reported delete content the registry still knew was referenced.
/// If a future change reintroduces it, this test fails, and that failure
/// is the point — see `manifest_reap_sql` and
/// `gc_reference_authority_integration_tests`.
2026-08-23 15:52:01 +02:00
#[tokio::test]
async fn manifest_reap_statement_is_stable () {
let sql = DedupService ::new_stub (). manifest_reap_sql ;
let expected = r #"DELETE FROM storage.chunk_manifests
WHERE ctid = ANY(
SELECT ctid
FROM storage.chunk_manifests m
2026-09-04 12:30:12 +02:00
WHERE NOT (EXISTS (SELECT 1 FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash)
2026-08-25 07:21:32 +02:00
OR EXISTS (SELECT 1 FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash)
OR EXISTS (SELECT 1 FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))
2026-08-23 15:52:01 +02:00
LIMIT $1
)
RETURNING file_hash, chunk_hashes, total_size"# ;
assert_eq! ( sql , expected , "reap statement changed: \n {sql}" );
2026-09-04 12:30:12 +02:00
assert! (
! sql . contains ( "ref_count" ),
"ref_count is back in the reap predicate — the counter must not be \
able to delete data on its own"
);
2026-08-23 15:52:01 +02:00
}
/// The reap predicate must never match a manifest that some source still
/// references. With an empty registry `NOT (...)` would have no operands,
/// so the builder refuses rather than emitting a statement that deletes
/// every manifest in the database.
#[test]
#[should_panic(expected = "no manifest-level blob reference source" )]
fn empty_registry_refuses_to_build_reap_statement () {
let _ = manifest_reap_sql ( & BlobReferenceRegistry ::new ());
}
2026-09-04 21:43:49 +02:00
#[test]
#[should_panic(expected = "no chunk-level blob reference source" )]
fn empty_registry_refuses_to_build_blob_reap_statement () {
let _ = blob_reap_sql ( & BlobReferenceRegistry ::new ());
}
/// Golden test for GC phase 2, same purpose as the manifest one.
///
/// Note what this pins that the manifest statement does not: the two
/// hardcoded `NOT EXISTS` guards **and** the registry predicate, ANDed.
/// The registry fragment is not a replacement here — see `blob_reap_sql`
/// for why substituting it would reap a legacy blob row mid-rechunk.
#[tokio::test]
async fn blob_reap_statement_is_stable () {
let sql = DedupService ::new_stub (). blob_reap_sql ;
let expected = r #"DELETE FROM storage.blobs
WHERE ctid = ANY(
SELECT b.ctid FROM storage.blobs b
WHERE b.ref_count <= 0
AND (b.orphaned_at IS NULL
OR b.orphaned_at < now() - ($2::int * interval '1 second'))
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
)
AND NOT EXISTS (
SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash
)
AND NOT (EXISTS (SELECT 1 FROM storage.files cnt_f WHERE cnt_f.blob_hash = b.hash AND NOT EXISTS (SELECT 1 FROM storage.chunk_manifests cnt_m WHERE cnt_m.file_hash = cnt_f.blob_hash))
OR EXISTS (SELECT 1 FROM storage.chunk_manifests cnt_m WHERE b.hash = ANY(cnt_m.chunk_hashes)))
LIMIT $1
)
RETURNING hash, size"# ;
assert_eq! ( sql , expected , "blob reap statement changed: \n {sql}" );
}
/// The reason phase 2 became registry-driven at all.
///
/// Today no source contributes at [`RefLevel::Chunk`] beyond files and
/// manifests, so the registry conjunct is operationally redundant and a
/// golden test alone would not notice if it stopped being wired up. This
/// registers a synthetic chunk-level source and asserts its fragment
/// reaches the statement — which is what stops a future
/// `content_derived_blobs`-style table from being silently missed the way
/// Phase 1's hardcoded cross-check missed them.
#[tokio::test]
async fn a_new_chunk_level_source_reaches_the_blob_reap_statement () {
use crate ::application ::ports ::blob_reference_ports ::BlobReferenceSource ;
struct FakeChunkSource ;
#[async_trait::async_trait]
impl BlobReferenceSource for FakeChunkSource {
fn source_name ( & self ) -> & 'static str {
"fake_chunk_source"
}
fn ref_count_sql ( & self , level : RefLevel , outer : & str ) -> Option < String > {
self . ref_exists_sql ( level , outer )
}
fn ref_exists_sql ( & self , level : RefLevel , outer : & str ) -> Option < String > {
match level {
RefLevel ::Chunk => Some ( format! (
"EXISTS (SELECT 1 FROM storage.zzz_fake WHERE blob_hash = {outer} )"
)),
RefLevel ::Manifest => None ,
}
}
async fn count_references ( & self , _hash : & str ) -> Result < u64 , DomainError > {
Ok ( 0 )
}
async fn list_referenced_blobs (
& self ,
_cursor : Option < Vec < u8 >> ,
_limit : usize ,
) -> Result < ( Vec < String > , Option < Vec < u8 >> ), DomainError > {
Ok (( Vec ::new (), None ))
}
}
let mut registry = BlobReferenceRegistry ::new ();
registry . register ( Arc ::new ( FakeChunkSource ));
let sql = blob_reap_sql ( & registry );
assert! (
sql . contains ( "storage.zzz_fake" ),
"a chunk-level source must reach the phase-2 reap guard: \n {sql}"
);
}
2026-04-14 23:17:39 +02:00
use std ::collections ::HashSet ;
use tempfile ::NamedTempFile ;
/// Helper: write `data` to a temp file and return its path.
async fn write_temp_file ( data : & [ u8 ]) -> NamedTempFile {
let file = NamedTempFile ::new (). unwrap ();
tokio ::fs ::write ( file . path (), data ). await . unwrap ();
file
}
2026-06-11 13:06:33 +00:00
/// One chunk as seen by the streaming analyser.
struct TestChunk {
hash : String ,
offset : usize ,
length : usize ,
}
/// Run the exact same streaming chunker the ingest engine uses
/// (`AsyncStreamCDC` + the production CDC parameters) over an in-memory
/// buffer, feeding it in `frame`-sized pieces to exercise the refill
/// logic the same way HTTP body frames do.
///
/// Returns the whole-stream BLAKE3 plus per-chunk metadata.
async fn stream_cdc ( data : & [ u8 ], frame : usize ) -> ( String , Vec < TestChunk > ) {
let frames : Vec < Result < Bytes , std ::io ::Error >> = data
. chunks ( frame . max ( 1 ))
. map ( | c | Ok ( Bytes ::copy_from_slice ( c )))
. collect ();
let reader = StreamReader ::new ( Box ::pin ( stream ::iter ( frames )));
let mut chunker = fastcdc ::v2020 ::AsyncStreamCDC ::new (
reader ,
CDC_MIN_CHUNK ,
CDC_AVG_CHUNK ,
CDC_MAX_CHUNK ,
);
let chunk_stream = chunker . as_stream ();
futures ::pin_mut! ( chunk_stream );
let mut file_hasher = blake3 ::Hasher ::new ();
let mut chunks = Vec ::new ();
while let Some ( item ) = chunk_stream . next (). await {
let chunk = item . expect ( "in-memory stream cannot fail" );
file_hasher . update ( & chunk . data );
chunks . push ( TestChunk {
hash : blake3 ::hash ( & chunk . data ). to_hex (). to_string (),
offset : chunk . offset as usize ,
length : chunk . length ,
});
}
( file_hasher . finalize (). to_hex (). to_string (), chunks )
}
const TEST_FRAME : usize = 64 * 1024 ; // typical HTTP body frame size
// ── Stream chunking ≡ slice chunking ─────────────────────────
//
// The whole dedup index hinges on this invariant: the boundaries (and
// therefore the chunk hashes) produced by the streaming chunker must be
// identical to FastCDC over the full in-memory slice, regardless of how
// the bytes were framed on the wire. Pre-streaming blobs were chunked
// via mmap + slice FastCDC — their chunks must keep deduplicating
// against newly streamed uploads.
#[tokio::test]
async fn test_stream_chunking_matches_slice_chunking () {
let data : Vec < u8 > = ( 0 .. 4 * 1024 * 1024 )
. map ( | i | (( i as u64 ). wrapping_mul ( 6364136223846793005 ). wrapping_add ( 1 )) as u8 )
. collect ();
let slice_chunks : Vec < ( usize , usize ) > =
fastcdc ::v2020 ::FastCDC ::new ( & data , CDC_MIN_CHUNK , CDC_AVG_CHUNK , CDC_MAX_CHUNK )
. map ( | c | ( c . offset , c . length ))
. collect ();
for frame in [ 7 usize , 4096 , TEST_FRAME , data . len ()] {
let ( _ , streamed ) = stream_cdc ( & data , frame ). await ;
assert_eq! (
streamed . len (),
slice_chunks . len (),
"chunk count must not depend on framing (frame={frame})"
);
for ( s , ( offset , length )) in streamed . iter (). zip ( slice_chunks . iter ()) {
assert_eq! (( s . offset , s . length ), ( * offset , * length ), "frame={frame}" );
let expected = blake3 ::hash ( & data [ * offset ..* offset + * length ])
. to_hex ()
. to_string ();
assert_eq! ( s . hash , expected , "frame={frame}" );
}
}
}
2026-04-14 23:17:39 +02:00
// ── Determinism ──────────────────────────────────────────────
#[tokio::test]
async fn test_cdc_deterministic_same_content () {
let data = vec! [ 42 u8 ; 512 * 1024 ]; // 512 KB of 0x2A
2026-06-11 13:06:33 +00:00
let ( hash1 , chunks1 ) = stream_cdc ( & data , TEST_FRAME ). await ;
let ( hash2 , chunks2 ) = stream_cdc ( & data , 4096 ). await ;
2026-04-14 23:17:39 +02:00
assert_eq! ( hash1 , hash2 , "same content must produce same file hash" );
assert_eq! (
chunks1 . len (),
chunks2 . len (),
"same content must produce same chunk count"
);
for ( c1 , c2 ) in chunks1 . iter (). zip ( chunks2 . iter ()) {
assert_eq! ( c1 . hash , c2 . hash );
assert_eq! ( c1 . offset , c2 . offset );
assert_eq! ( c1 . length , c2 . length );
}
}
2026-06-11 13:06:33 +00:00
// ── Empty stream ─────────────────────────────────────────────
2026-04-14 23:17:39 +02:00
#[tokio::test]
2026-06-11 13:06:33 +00:00
async fn test_cdc_empty_stream () {
let ( hash , chunks ) = stream_cdc ( b "" , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
2026-06-11 13:06:33 +00:00
assert! ( chunks . is_empty (), "empty stream must produce zero chunks" );
2026-04-14 23:17:39 +02:00
assert_eq! ( hash , blake3 ::hash ( b "" ). to_hex (). to_string ());
}
// ── Small file (below min chunk) → single chunk ──────────────
#[tokio::test]
async fn test_cdc_small_file_single_chunk () {
let data = b "Hello, OxiCloud CDC dedup!" ;
2026-06-11 13:06:33 +00:00
let ( hash , chunks ) = stream_cdc ( data , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
assert_eq! ( chunks . len (), 1 , "tiny file must be a single chunk" );
assert_eq! ( chunks [ 0 ]. offset , 0 );
assert_eq! ( chunks [ 0 ]. length , data . len ());
assert_eq! ( hash , blake3 ::hash ( data ). to_hex (). to_string ());
}
// ── Chunk sizes within CDC bounds ────────────────────────────
#[tokio::test]
async fn test_cdc_chunk_sizes_within_bounds () {
// 4 MB file of pseudo-random data (deterministic seed)
let data : Vec < u8 > = ( 0 .. 4 * 1024 * 1024 )
. map ( | i | (( i as u64 ). wrapping_mul ( 6364136223846793005 ). wrapping_add ( 1 )) as u8 )
. collect ();
2026-06-11 13:06:33 +00:00
let ( _ , chunks ) = stream_cdc ( & data , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
assert! ( chunks . len () > 1 , "4 MB should produce multiple chunks" );
// All non-last chunks must be within [min, max]
for ( i , chunk ) in chunks . iter (). enumerate () {
let is_last = i == chunks . len () - 1 ;
if ! is_last {
assert! (
2026-04-27 09:29:45 +02:00
chunk . length >= CDC_MIN_CHUNK ,
2026-04-14 23:17:39 +02:00
"non-last chunk {} too small: {} < {}" ,
i ,
chunk . length ,
CDC_MIN_CHUNK ,
);
}
assert! (
2026-04-27 09:29:45 +02:00
chunk . length <= CDC_MAX_CHUNK ,
2026-04-14 23:17:39 +02:00
"chunk {} too large: {} > {}" ,
i ,
chunk . length ,
CDC_MAX_CHUNK ,
);
}
}
// ── File hash matches hash_file() ────────────────────────────
#[tokio::test]
async fn test_cdc_file_hash_matches_hash_file () {
let data : Vec < u8 > = ( 0 .. 1024 * 1024 ). map ( | i | ( i % 251 ) as u8 ). collect ();
let f = write_temp_file ( & data ). await ;
2026-06-11 13:06:33 +00:00
let ( cdc_hash , _ ) = stream_cdc ( & data , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
let standalone_hash = DedupService ::hash_file ( f . path ()). await . unwrap ();
assert_eq! (
cdc_hash , standalone_hash ,
2026-06-11 13:06:33 +00:00
"streamed file hash must match standalone hash_file()"
2026-04-14 23:17:39 +02:00
);
}
2026-06-11 13:06:33 +00:00
// ── Reassembly: chunks are contiguous and cover the file ─────
2026-04-14 23:17:39 +02:00
#[tokio::test]
async fn test_cdc_chunks_are_contiguous () {
let data : Vec < u8 > = ( 0 .. 2 * 1024 * 1024 ). map ( | i | ( i % 199 ) as u8 ). collect ();
2026-06-11 13:06:33 +00:00
let ( _ , chunks ) = stream_cdc ( & data , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
let mut expected_offset = 0 usize ;
for ( i , chunk ) in chunks . iter (). enumerate () {
assert_eq! (
chunk . offset , expected_offset ,
"chunk {} starts at {} but expected {}" ,
i , chunk . offset , expected_offset
);
expected_offset += chunk . length ;
}
assert_eq! ( expected_offset , data . len (), "chunks must cover entire file" );
}
// ── Sub-file dedup: similar files share chunks ───────────────
#[tokio::test]
async fn test_cdc_similar_files_share_chunks () {
// Create a base file of 2 MB with random-ish data
let base : Vec < u8 > = ( 0 .. 2 * 1024 * 1024 )
. map ( | i | (( i as u64 ). wrapping_mul ( 6364136223846793005 ). wrapping_add ( 1 )) as u8 )
. collect ();
// Modified file: change only the last 64 KB
let mut modified = base . clone ();
let start = modified . len () - 64 * 1024 ;
for b in & mut modified [ start .. ] {
* b = b . wrapping_add ( 1 );
}
2026-06-11 13:06:33 +00:00
let ( hash_base , chunks_base ) = stream_cdc ( & base , TEST_FRAME ). await ;
let ( hash_mod , chunks_mod ) = stream_cdc ( & modified , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
// File hashes must differ
assert_ne! (
hash_base , hash_mod ,
"modified file must have different hash"
);
// Collect chunk hashes
let base_set : HashSet <& str > = chunks_base . iter (). map ( | c | c . hash . as_str ()). collect ();
let mod_set : HashSet <& str > = chunks_mod . iter (). map ( | c | c . hash . as_str ()). collect ();
let shared = base_set . intersection ( & mod_set ). count ();
// With only the last 64 KB changed, most chunks should be shared.
// The first ~1.9 MB of content is identical → expect significant overlap.
let min_expected_shared = chunks_base . len (). min ( chunks_mod . len ()) / 2 ;
assert! (
shared >= min_expected_shared ,
"expected at least {} shared chunks between similar files, got {} \
(base: {} chunks, modified: {} chunks)" ,
min_expected_shared ,
shared ,
chunks_base . len (),
chunks_mod . len ()
);
}
// ── Large file produces expected chunk count ──────────────────
#[tokio::test]
async fn test_cdc_large_file_chunk_count () {
// 8 MB should produce roughly 8MB / 256KB ≈ 32 chunks (±)
let data : Vec < u8 > = ( 0 .. 8 * 1024 * 1024 )
. map ( | i | (( i as u64 ). wrapping_mul ( 2862933555777941757 ). wrapping_add ( 3 )) as u8 )
. collect ();
2026-06-11 13:06:33 +00:00
let ( _ , chunks ) = stream_cdc ( & data , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
// With 256KB avg, expect 20-60 chunks for 8MB
assert! (
chunks . len () >= 8 && chunks . len () <= 128 ,
"8 MB file should produce 8-128 chunks (avg 256KB), got {}" ,
chunks . len ()
);
let total_size : usize = chunks . iter (). map ( | c | c . length ). sum ();
assert_eq! (
total_size ,
data . len (),
"total chunk sizes must equal file size"
);
}
// ── Prefix insert: CDC shifts only locally ───────────────────
#[tokio::test]
async fn test_cdc_insert_at_beginning_preserves_later_chunks () {
// Base file: 2 MB of deterministic data
let base : Vec < u8 > = ( 0 .. 2 * 1024 * 1024 )
. map ( | i | (( i as u64 ). wrapping_mul ( 6364136223846793005 ). wrapping_add ( 1 )) as u8 )
. collect ();
// Insert 128 KB at the beginning (simulates a header change)
let prefix : Vec < u8 > = ( 0 .. 128 * 1024 ). map ( | i | ( i % 173 ) as u8 ). collect ();
let mut with_prefix = prefix ;
with_prefix . extend_from_slice ( & base );
2026-06-11 13:06:33 +00:00
let ( _ , chunks_base ) = stream_cdc ( & base , TEST_FRAME ). await ;
let ( _ , chunks_prefix ) = stream_cdc ( & with_prefix , TEST_FRAME ). await ;
2026-04-14 23:17:39 +02:00
let base_set : HashSet <& str > = chunks_base . iter (). map ( | c | c . hash . as_str ()). collect ();
let prefix_set : HashSet <& str > = chunks_prefix . iter (). map ( | c | c . hash . as_str ()). collect ();
// CDC's content-defined boundaries mean chunks after the insertion
// should resynchronize — we expect *some* shared chunks, proving
// CDC is better than fixed-size chunking (which would share zero).
let shared = base_set . intersection ( & prefix_set ). count ();
assert! (
shared > 0 ,
"CDC should resynchronize and share chunks after insertion \
(base: {} chunks, with-prefix: {} chunks, shared: 0)" ,
chunks_base . len (),
chunks_prefix . len ()
);
}
2026-06-11 13:06:33 +00:00
// ── ChunkIngestOutcome helpers ───────────────────────────────
#[test]
fn test_distinct_hashes_deduplicates_preserving_order () {
let outcome = ChunkIngestOutcome {
file_hash : String ::new (),
total_size : 0 ,
chunk_hashes : vec ! [ "a" . into (), "b" . into (), "a" . into (), "c" . into (), "b" . into ()],
chunk_sizes : vec ! [ 1 , 2 , 1 , 3 , 2 ],
newly_written : 0 ,
};
assert_eq! ( outcome . distinct_hashes (), vec! [ "a" , "b" , "c" ]);
}
2026-07-22 02:06:04 +02:00
#[test]
fn integrity_phase_one_deduplicates_probes_but_replays_each_occurrence () {
let manifests : Vec < IntegrityManifest > = vec! [
(
"file-a" . into (),
vec! [ "shared" . into (), "missing-x" . into (), "shared" . into ()],
vec! [ 256 , 256 , 257 ],
1 ,
),
( "file-b" . into (), vec! [ "shared" . into ()], vec! [ 999 ], 999 ),
( "bad" . into (), vec! [ "never-query" . into ()], vec! [], 0 ),
];
let mut sizes = integrity_chunk_sizes ( & manifests );
assert_eq! (
sizes . hashes . len (),
2 ,
"shared hash must be probed only once"
);
assert_eq! (
sizes . hashes ,
vec! [ "missing-x" , "shared" ],
"borrowed keys must be sorted for binary-search replay"
);
assert! (
sizes . hashes . binary_search ( & "never-query" ). is_err (),
"malformed manifests keep the historical no-probe behaviour"
);
let shared = sizes . hashes . binary_search ( & "shared" ). unwrap ();
sizes . sizes [ shared ] = Some ( 256 );
assert_eq! (
integrity_manifest_issues ( & manifests , & sizes ),
vec! [
"Manifest file-a: total_size 1 != sum of chunk_sizes 769" ,
"Manifest file-a chunk missing-x: missing in backend" ,
"Manifest file-a chunk shared: size mismatch (expected 257, actual 256)" ,
"Manifest file-b chunk shared: size mismatch (expected 999, actual 256)" ,
"Manifest bad: chunk_hashes/chunk_sizes length mismatch" ,
]
);
}
#[test]
fn integrity_phase_one_serial_fast_path_covers_zero_latency_break_even () {
let manifest = | name : & str , count : usize | -> IntegrityManifest {
(
name . into (),
( 0 .. count ). map ( | i | format! ( "hash- {i} " )). collect (),
vec! [ 256 ; count ],
( count * 256 ) as i64 ,
)
};
assert! ( integrity_uses_serial_fast_path ( & [ manifest ( "one" , 2 )]));
assert! ( integrity_uses_serial_fast_path ( & [
manifest ( "one" , 1 ),
manifest ( "two" , 1 ),
]));
assert! ( integrity_uses_serial_fast_path ( & [ manifest ( "one" , 4 )]));
assert! (
! integrity_uses_serial_fast_path ( & [ manifest ( "one" , 5 )]),
"the measured concurrent path starts above four occurrences"
);
}
2026-04-14 23:17:39 +02:00
}
2026-06-11 10:43:45 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// Integration tests for the legacy re-chunk migration — require the test
// database (run via `just test-integration`, which spawns it and applies
// migrations). Gated on `--cfg integration_tests` like the other PG suites.
//
// Each test seeds its own synthetic "legacy" state (a whole-file blob row in
// `storage.blobs` + file rows pointing at it, no manifest) with unique
// `rust-test-rechunk-*` names, then runs the sweep and asserts on the DB
// state for ITS hash only — concurrent test sweeps may migrate each other's
// blobs first, which is fine (and exercises the idempotency paths).
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(integration_tests)]
#[allow(dead_code)]
mod rechunk_integration_tests {
use super ::* ;
use crate ::infrastructure ::services ::encrypted_blob_backend ::EncryptedBlobBackend ;
use crate ::infrastructure ::services ::local_blob_backend ::LocalBlobBackend ;
use crate ::integration_test_support ::{ ensure_clean_test_db , test_db_url };
use sqlx ::Row ;
use sqlx ::postgres ::PgPoolOptions ;
use tempfile ::TempDir ;
use uuid ::Uuid ;
async fn test_pool () -> Arc < PgPool > {
let pool = PgPoolOptions ::new ()
. max_connections ( 4 )
. connect ( & test_db_url ())
. await
. expect ( "connect to test DB — run tests/common/spawn-db.sh first" );
ensure_clean_test_db ( & pool ). await ;
Arc ::new ( pool )
}
2026-06-18 13:29:41 +02:00
/// Returns `(user_id, drive_id)`. Post-D0 every internal user has a
/// default Personal drive (provisioned by `PersonalDriveLifecycleHook`
/// during init-test-schema.sh's user seeding); the JOIN below picks
/// the user-drive pair atomically so test fixtures can insert into
/// `storage.files` with both `user_id` and `drive_id` populated.
async fn seed_user ( pool : & PgPool ) -> ( Uuid , Uuid ) {
sqlx ::query (
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1" ,
)
. fetch_one ( pool )
. await
. map ( | r | ( r . get ::< Uuid , _ > ( "user_id" ), r . get ::< Uuid , _ > ( "drive_id" )))
. expect ( "auth.users + storage.drives must be seeded (init-test-schema.sh)" )
2026-06-11 10:43:45 +00:00
}
/// Plain local backend in a fresh temp dir.
async fn local_svc ( pool : & Arc < PgPool > , dir : & TempDir ) -> DedupService {
let backend = Arc ::new ( LocalBlobBackend ::new ( & dir . path (). join ( "blobs" )));
backend . initialize (). await . expect ( "init backend" );
DedupService ::new ( backend , pool . clone (), pool . clone ())
}
/// AES-256-GCM-encrypted local backend in a fresh temp dir.
async fn encrypted_svc ( pool : & Arc < PgPool > , dir : & TempDir ) -> DedupService {
let inner = Arc ::new ( LocalBlobBackend ::new ( & dir . path (). join ( "blobs" )));
inner . initialize (). await . expect ( "init backend" );
let key = EncryptedBlobBackend ::generate_key ();
2026-08-01 23:06:31 +02:00
let backend = Arc ::new ( EncryptedBlobBackend ::new_single_aes ( inner , & key ));
2026-06-11 10:43:45 +00:00
DedupService ::new ( backend , pool . clone (), pool . clone ())
}
/// Non-trivial content of `len` bytes + a random 16-byte tail, so every
/// invocation produces a unique hash — stale rows left behind by a
/// previously failed run (panics skip cleanup) can never collide with
/// the current one.
fn content ( len : usize , salt : u8 ) -> Vec < u8 > {
let mut data : Vec < u8 > = ( 0 .. len )
. map ( | i | {
(( i % 251 ) as u8 )
. wrapping_add ( salt )
. wrapping_add (( i / 7919 ) as u8 )
})
. collect ();
data . extend_from_slice ( Uuid ::new_v4 (). as_bytes ());
data
}
/// Seed a pre-CDC legacy blob: physical blob via the backend + a
/// `storage.blobs` row (ref_count = n_files) + `n_files` file rows.
/// Returns (hash, file row ids). When `corrupt_stored_bytes` is Some,
/// the PHYSICAL content differs from the indexed hash.
async fn seed_legacy (
svc : & DedupService ,
pool : & PgPool ,
dir : & TempDir ,
data : & [ u8 ],
n_files : i32 ,
label : & str ,
corrupt_stored_bytes : Option <& [ u8 ] > ,
) -> ( String , Vec < Uuid > ) {
let hash = blake3 ::hash ( data ). to_hex (). to_string ();
let stored = corrupt_stored_bytes . unwrap_or ( data );
let src = dir . path (). join ( format! ( "seed- {label} .tmp" ));
tokio ::fs ::write ( & src , stored ). await . expect ( "write seed" );
svc . backend (). put_blob ( & hash , & src ). await . expect ( "put blob" );
sqlx ::query (
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, $2, $3, 'application/octet-stream')
ON CONFLICT (hash) DO UPDATE SET ref_count = storage.blobs.ref_count + $3" ,
)
. bind ( & hash )
. bind ( data . len () as i64 )
. bind ( n_files )
. execute ( pool )
. await
. expect ( "insert legacy blob row" );
2026-07-02 21:18:01 +02:00
let ( _user_id , drive_id ) = seed_user ( pool ). await ;
2026-06-11 10:43:45 +00:00
let mut file_ids = Vec ::new ();
for i in 0 .. n_files {
let name = format! (
"rust-test-rechunk- {label} - {} - {i} " ,
& Uuid ::new_v4 (). to_string ()[ .. 8 ]
);
2026-07-02 21:18:01 +02:00
// Post-D7: `user_id` omitted — column is nullable and unused
// on new rows.
2026-06-11 10:43:45 +00:00
let id : Uuid = sqlx ::query_scalar (
2026-07-02 21:18:01 +02:00
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id" ,
2026-06-11 10:43:45 +00:00
)
. bind ( & name )
2026-06-18 13:29:41 +02:00
. bind ( drive_id )
2026-06-11 10:43:45 +00:00
. bind ( & hash )
. bind ( data . len () as i64 )
. fetch_one ( pool )
. await
. expect ( "insert file row" );
file_ids . push ( id );
}
( hash , file_ids )
}
/// Best-effort cleanup of everything a test seeded/created for `hash`.
async fn cleanup ( pool : & PgPool , hash : & str , file_ids : & [ Uuid ]) {
let chunks : Option < Vec < String >> = sqlx ::query_scalar (
"SELECT chunk_hashes FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( hash )
. fetch_optional ( pool )
. await
. unwrap_or ( None );
let _ = sqlx ::query ( "DELETE FROM storage.files WHERE id = ANY($1)" )
. bind ( file_ids )
. execute ( pool )
. await ;
// Also scrub test-named rows from previously failed runs (panics
// skip the end-of-test cleanup) that reference the same hash.
let _ = sqlx ::query (
"DELETE FROM storage.files
WHERE blob_hash = $1 AND name LIKE 'rust-test-rechunk-%'" ,
)
. bind ( hash )
. execute ( pool )
. await ;
let _ = sqlx ::query ( "DELETE FROM storage.chunk_manifests WHERE file_hash = $1" )
. bind ( hash )
. execute ( pool )
. await ;
let mut to_drop = chunks . unwrap_or_default ();
to_drop . push ( hash . to_string ());
let _ = sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = ANY($1)" )
. bind ( & to_drop )
. execute ( pool )
. await ;
}
async fn collect ( svc : & DedupService , hash : & str ) -> Vec < u8 > {
let mut out = Vec ::new ();
let mut stream = svc . read_blob_stream ( hash ). await . expect ( "stream" );
while let Some ( chunk ) = stream . next (). await {
out . extend_from_slice ( & chunk . expect ( "chunk" ));
}
out
}
/// Manifest row (ref_count, total_size, chunk_hashes), if present.
async fn manifest ( pool : & PgPool , hash : & str ) -> Option < ( i32 , i64 , Vec < String > ) > {
sqlx ::query_as (
"SELECT ref_count, total_size, chunk_hashes
FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( hash )
. fetch_optional ( pool )
. await
. expect ( "manifest query" )
}
async fn blob_row ( pool : & PgPool , hash : & str ) -> Option < i32 > {
sqlx ::query_scalar ( "SELECT ref_count FROM storage.blobs WHERE hash = $1" )
. bind ( hash )
. fetch_optional ( pool )
. await
. expect ( "blob query" )
}
// ── 1. Multi-chunk blob: refs move to manifest, whole-file blob freed ──
#[tokio::test]
async fn rechunk_multi_chunk_moves_refs_and_frees_blob () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
// 3 MiB ⇒ ≥ 3 CDC chunks (max chunk = 1 MiB), 2 referencing files.
let data = content ( 3 * 1024 * 1024 , 1 );
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 2 , "multi" , None ). await ;
assert! ( svc . count_legacy_blobs (). await . unwrap () >= 1 );
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
let ( rc , total , chunks ) = manifest ( & pool , & hash ). await . expect ( "manifest created" );
assert_eq! ( rc , 2 , "both file references must move to the manifest" );
assert_eq! ( total , data . len () as i64 );
assert! ( chunks . len () >= 3 , "3 MiB must split into ≥3 chunks" );
// Whole-file blob fully dereferenced: row gone, physical file gone.
assert_eq! ( blob_row ( & pool , & hash ). await , None );
assert! ( ! svc . backend (). blob_exists ( & hash ). await . unwrap ());
// Every chunk row carries exactly the manifest's reference.
for c in & chunks {
assert_eq! ( blob_row ( & pool , c ). await , Some ( 1 ), "chunk {c}" );
}
// Content integrity through the chunked read path + a Range that
// crosses a chunk boundary.
assert_eq! ( collect ( & svc , & hash ). await , data );
let mut ranged = Vec ::new ();
let mut s = svc
. read_blob_range_stream ( & hash , 1_500_000 , Some ( 1_500_100 ))
. await
. expect ( "range" );
while let Some ( chunk ) = s . next (). await {
ranged . extend_from_slice ( & chunk . expect ( "chunk" ));
}
assert_eq! ( ranged , & data [ 1_500_000 .. 1_500_100 ]);
cleanup ( & pool , & hash , & files ). await ;
}
// ── 2. Single-chunk blob: physical blob IS the chunk and must survive ──
#[tokio::test]
async fn rechunk_single_chunk_keeps_physical_blob () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
// 50 KB < CDC_MIN_CHUNK ⇒ exactly one chunk whose hash == file hash.
let data = content ( 50 * 1024 , 2 );
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 1 , "single" , None ). await ;
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
let ( rc , total , chunks ) = manifest ( & pool , & hash ). await . expect ( "manifest created" );
assert_eq! ( rc , 1 );
assert_eq! ( total , data . len () as i64 );
assert_eq! ( chunks , vec! [ hash . clone ()], "the file IS its single chunk" );
// Blob row survives with exactly the manifest's chunk reference;
// the physical bytes were never rewritten.
assert_eq! ( blob_row ( & pool , & hash ). await , Some ( 1 ));
assert! ( svc . backend (). blob_exists ( & hash ). await . unwrap ());
assert_eq! ( collect ( & svc , & hash ). await , data );
cleanup ( & pool , & hash , & files ). await ;
}
// ── 3. Corrupt blob (content ≠ hash): fail, count, leave untouched ──
#[tokio::test]
async fn rechunk_corrupt_blob_left_untouched () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
let data = content ( 100 * 1024 , 3 );
let mut wrong = data . clone ();
wrong [ 0 ] ^= 0xFF ;
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 1 , "corrupt" , Some ( & wrong )). await ;
let report = svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
assert! ( report . failed >= 1 , "the corrupt blob must be counted" );
// Nothing was touched: no manifest, blob row + refs + file intact.
assert_eq! ( manifest ( & pool , & hash ). await , None );
assert_eq! ( blob_row ( & pool , & hash ). await , Some ( 1 ));
assert! ( svc . backend (). blob_exists ( & hash ). await . unwrap ());
let files_left : i64 =
sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.files WHERE blob_hash = $1" )
. bind ( & hash )
. fetch_one ( pool . as_ref ())
. await
. unwrap ();
assert_eq! ( files_left , 1 );
cleanup ( & pool , & hash , & files ). await ;
}
// ── 4. Empty blob: empty manifest, empty stream ──
#[tokio::test]
async fn rechunk_empty_blob () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
// The empty-content hash is a constant (no per-run uniqueness is
// possible), so scrub any leftovers from a previously failed run.
let empty_hash = blake3 ::hash ( & []). to_hex (). to_string ();
cleanup ( & pool , & empty_hash , & []). await ;
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & [], 1 , "empty" , None ). await ;
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
let ( rc , total , chunks ) = manifest ( & pool , & hash ). await . expect ( "manifest created" );
assert_eq! (( rc , total ), ( 1 , 0 ));
assert! ( chunks . is_empty ());
assert! ( collect ( & svc , & hash ). await . is_empty ());
cleanup ( & pool , & hash , & files ). await ;
}
// ── 5. Encrypted backend: spool decrypts, chunks re-encrypt, Range works ──
#[tokio::test]
async fn rechunk_encrypted_multi_chunk_roundtrip () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = encrypted_svc ( & pool , & dir ). await ;
let data = content ( 2 * 1024 * 1024 + 333 , 4 );
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 1 , "enc" , None ). await ;
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
let ( rc , total , chunks ) = manifest ( & pool , & hash ). await . expect ( "manifest created" );
assert_eq! ( rc , 1 );
assert_eq! ( total , data . len () as i64 );
assert! ( chunks . len () >= 2 );
assert_eq! ( blob_row ( & pool , & hash ). await , None , "whole-file blob freed" );
// The point of the whole migration: a Range read now decrypts only
// the overlapping ≤1 MiB chunks, and returns correct plaintext.
assert_eq! ( collect ( & svc , & hash ). await , data );
let mut ranged = Vec ::new ();
let mut s = svc
. read_blob_range_stream ( & hash , 1_100_000 , Some ( 1_100_064 ))
. await
. expect ( "range" );
while let Some ( chunk ) = s . next (). await {
ranged . extend_from_slice ( & chunk . expect ( "chunk" ));
}
assert_eq! ( ranged , & data [ 1_100_000 .. 1_100_064 ]);
cleanup ( & pool , & hash , & files ). await ;
}
2026-08-02 19:20:23 +02:00
// ─── stream_blob_to_tempfile — CDC-aware read to a filesystem path ───
//
// Regression tests for the fix landed on `fix/services-use-blob-abstraction`:
// audio_metadata_service, media_metadata_service, and face_indexing_service
// all read blob content via DedupService (`read_blob_bytes` /
// `stream_blob_to_tempfile`), NOT the raw `BlobStorageBackend`. If someone
// reverts a service to `backend.get_blob_stream(hash)`, this test fails
// because `hash` is a chunk-manifest hash — the physical backend has no
// blob at that key. Bug returns silently otherwise; these tests catch it.
/// Local backend: seed a > 64 KiB blob, rechunk to CDC, then call
/// `stream_blob_to_tempfile` and verify the tempfile contents match
/// the original. Proves the CDC chunk-concat path works.
#[tokio::test]
async fn stream_blob_to_tempfile_reads_cdc_chunked_local () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
// 200 KiB → forced multi-chunk after rechunk_legacy_blobs.
let data = content ( 200 * 1024 , 33 );
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 1 , "cdc-local" , None ). await ;
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
// Sanity: rechunk actually produced a manifest (i.e. we're on the
// CDC path, not the legacy-fallback branch of read_blob_stream).
assert! (
manifest ( & pool , & hash ). await . is_some (),
"expected a CDC manifest after rechunk (test wouldn't cover the bug otherwise)"
);
// New method — the entry point audio/media services use.
let temp_dir = TempDir ::new (). unwrap ();
let named = svc
. stream_blob_to_tempfile ( & hash , temp_dir . path (), ".bin" )
. await
. expect ( "stream_blob_to_tempfile must succeed on CDC-chunked blob" );
let round_tripped = tokio ::fs ::read ( named . path ()). await . expect ( "read tempfile" );
assert_eq! ( round_tripped , data , "tempfile content must match original" );
cleanup ( & pool , & hash , & files ). await ;
}
/// Encrypted backend variant — proves the wrapper stack (decryption
/// on read) is honoured. Same regression class: if a service reads
/// raw ciphertext instead of going through DedupService, this fails.
#[tokio::test]
async fn stream_blob_to_tempfile_reads_cdc_chunked_encrypted () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = encrypted_svc ( & pool , & dir ). await ;
let data = content ( 150 * 1024 , 77 );
let ( hash , files ) = seed_legacy ( & svc , & pool , & dir , & data , 1 , "cdc-enc" , None ). await ;
svc . rechunk_legacy_blobs (). await . expect ( "sweep" );
assert! (
manifest ( & pool , & hash ). await . is_some (),
"expected a CDC manifest after rechunk"
);
let temp_dir = TempDir ::new (). unwrap ();
let named = svc
. stream_blob_to_tempfile ( & hash , temp_dir . path (), ".bin" )
. await
. expect ( "stream_blob_to_tempfile must succeed on encrypted CDC blob" );
let round_tripped = tokio ::fs ::read ( named . path ()). await . expect ( "read tempfile" );
assert_eq! (
round_tripped , data ,
"tempfile content must match original plaintext (wrapper stack must decrypt transparently)"
);
cleanup ( & pool , & hash , & files ). await ;
}
2026-06-11 10:43:45 +00:00
}
2026-06-11 14:34:02 +00:00
2026-09-04 12:30:12 +02:00
/// Serializes every integration test that runs a **global** GC sweep.
///
/// GC sweeps the shared integration database, while each test intentionally
/// owns a different `TempDir`-backed blob store. Two sweep tests running
/// concurrently can therefore delete test A's row through test B's backend,
/// leaving A's physical blob behind and failing an assertion that has nothing
/// to do with the code under test. Production has one shared backend for the
/// swept database; serializing only these tests models that invariant.
///
/// **Any new test that calls `garbage_collect*` must take this guard**,
/// wherever it lives in this file. It sat inside
/// `delta_upload_integration_tests` until `gc_reference_authority_integration_tests`
/// was added without it and broke
/// `garbage_collect_honours_grace_window_and_references` — a failure that
/// appeared only in the full suite and pointed at the wrong test. Hoisted to
/// module scope so the next suite finds it.
///
/// `allow(dead_code)`: gated on a cfg flag rather than on `test`, so a plain
/// build with `--cfg integration_tests` compiles it while `#[tokio::test]`
/// drops every caller.
#[cfg(integration_tests)]
#[allow(dead_code)]
static GC_TEST_SERIALIZER : tokio ::sync ::Mutex < () > = tokio ::sync ::Mutex ::const_new (());
2026-06-11 14:34:02 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// Integration tests for the delta-upload primitives — the entitlement and
// verification rules the chunk-negotiation protocol stands on. Same gating
// and DB conventions as the re-chunk suite above.
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(integration_tests)]
#[allow(dead_code)]
mod delta_upload_integration_tests {
use super ::* ;
use crate ::infrastructure ::services ::local_blob_backend ::LocalBlobBackend ;
use crate ::integration_test_support ::{ ensure_clean_test_db , test_db_url };
use sqlx ::Row ;
use sqlx ::postgres ::PgPoolOptions ;
use tempfile ::TempDir ;
use uuid ::Uuid ;
async fn test_pool () -> Arc < PgPool > {
let pool = PgPoolOptions ::new ()
. max_connections ( 4 )
. connect ( & test_db_url ())
. await
. expect ( "connect to test DB — run tests/common/spawn-db.sh first" );
ensure_clean_test_db ( & pool ). await ;
Arc ::new ( pool )
}
2026-06-18 13:29:41 +02:00
/// Returns `(user_id, drive_id)` — same shape as the rechunk tests'
/// `seed_user`. Post-D0 every internal user has a default Personal
/// drive provisioned by `PersonalDriveLifecycleHook`.
async fn seed_user ( pool : & PgPool ) -> ( Uuid , Uuid ) {
sqlx ::query (
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1" ,
)
. fetch_one ( pool )
. await
. map ( | r | ( r . get ::< Uuid , _ > ( "user_id" ), r . get ::< Uuid , _ > ( "drive_id" )))
. expect ( "auth.users + storage.drives must be seeded (init-test-schema.sh)" )
2026-06-11 14:34:02 +00:00
}
async fn local_svc ( pool : & Arc < PgPool > , dir : & TempDir ) -> DedupService {
let backend = Arc ::new ( LocalBlobBackend ::new ( & dir . path (). join ( "blobs" )));
backend . initialize (). await . expect ( "init backend" );
DedupService ::new ( backend , pool . clone (), pool . clone ())
}
/// Store `data` through the streaming path and give `user_id` a file
/// row referencing it — making its chunks claimable by that user.
2026-06-25 08:16:45 +02:00
///
/// **Order matters.** BLAKE3 is deterministic, so the file row is
/// inserted BEFORE `store_from_stream` runs. This closes a race in
/// the shared test pool: Phase 1 of `garbage_collect()` deletes
/// manifests with `NOT EXISTS (file referencing it)`. With the old
/// order (store first, file second), a concurrent GC-invoking test
/// (`garbage_collect_honours_grace_window_and_references`,
/// `manifest_dereference_defers_chunk_reclamation_to_gc`) could
/// reap our manifest in the microsecond window between the two
/// statements, causing CI-flaky `RowNotFound` panics in producers
/// like `hash_chunk_sequence_recomputes_and_validates_sizes`.
2026-06-11 14:34:02 +00:00
async fn seed_owned_content (
svc : & DedupService ,
pool : & PgPool ,
2026-07-02 21:18:01 +02:00
_user_id : Uuid ,
2026-06-18 13:29:41 +02:00
drive_id : Uuid ,
2026-06-11 14:34:02 +00:00
data : & [ u8 ],
label : & str ,
) -> ( String , Vec < String > , Uuid ) {
2026-06-25 08:16:45 +02:00
let file_hash = blake3 ::hash ( data ). to_hex (). to_string ();
2026-06-11 14:34:02 +00:00
2026-07-02 21:18:01 +02:00
// Post-D7: `user_id` omitted — column is nullable and unused on
// new rows.
2026-06-11 14:34:02 +00:00
let file_id : Uuid = sqlx ::query_scalar (
2026-07-02 21:18:01 +02:00
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id" ,
2026-06-11 14:34:02 +00:00
)
. bind ( format! (
"rust-test-delta- {label} - {} " ,
& Uuid ::new_v4 (). to_string ()[ .. 8 ]
))
2026-06-18 13:29:41 +02:00
. bind ( drive_id )
2026-06-11 14:34:02 +00:00
. bind ( & file_hash )
. bind ( data . len () as i64 )
. fetch_one ( pool )
. await
. expect ( "file row" );
2026-06-25 08:16:45 +02:00
let source = stream ::iter ( vec! [ Ok ::< _ , std ::io ::Error > ( Bytes ::copy_from_slice ( data ))]);
let stored = svc
. store_from_stream ( source , Some ( "application/octet-stream" . into ()))
. await
. expect ( "store" );
assert_eq! (
stored . hash (),
file_hash ,
"pre-computed BLAKE3 must match CDC-store output"
);
let chunks : Vec < String > = sqlx ::query_scalar (
"SELECT UNNEST(chunk_hashes) FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( & file_hash )
. fetch_all ( pool )
. await
. expect ( "chunks" );
2026-06-11 14:34:02 +00:00
( file_hash , chunks , file_id )
}
async fn blob_ref ( pool : & PgPool , hash : & str ) -> Option < i32 > {
sqlx ::query_scalar ( "SELECT ref_count FROM storage.blobs WHERE hash = $1" )
. bind ( hash )
. fetch_optional ( pool )
. await
. expect ( "blob query" )
}
async fn cleanup ( pool : & PgPool , file_hash : & str , file_id : Uuid , extra_hashes : & [ String ]) {
let chunks : Option < Vec < String >> = sqlx ::query_scalar (
"SELECT chunk_hashes FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( file_hash )
. fetch_optional ( pool )
. await
. unwrap_or ( None );
let _ = sqlx ::query ( "DELETE FROM storage.files WHERE id = $1" )
. bind ( file_id )
. execute ( pool )
. await ;
let _ = sqlx ::query ( "DELETE FROM storage.chunk_manifests WHERE file_hash = $1" )
. bind ( file_hash )
. execute ( pool )
. await ;
let mut to_drop = chunks . unwrap_or_default ();
to_drop . push ( file_hash . to_string ());
to_drop . extend_from_slice ( extra_hashes );
let _ = sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = ANY($1)" )
. bind ( & to_drop )
. execute ( pool )
. await ;
}
fn content ( len : usize , salt : u8 ) -> Vec < u8 > {
let mut data : Vec < u8 > = ( 0 .. len )
. map ( | i | {
(( i % 251 ) as u8 )
. wrapping_add ( salt )
. wrapping_add (( i / 7919 ) as u8 )
})
. collect ();
data . extend_from_slice ( Uuid ::new_v4 (). as_bytes ());
data
}
// ── Entitlement: claimable vs pin ────────────────────────────
#[tokio::test]
async fn claim_and_pin_respect_ownership_and_orphans () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
2026-06-18 13:29:41 +02:00
let ( user , drive_id ) = seed_user ( & pool ). await ;
2026-06-11 14:34:02 +00:00
// Owned content (multi-chunk), one foreign chunk (ref 1, no file
// row for this user), one orphan (ref 0), one unknown hash.
let data = content ( 3 * 1024 * 1024 , 21 );
let ( file_hash , owned_chunks , file_id ) =
2026-06-18 13:29:41 +02:00
seed_owned_content ( & svc , & pool , user , drive_id , & data , "claim" ). await ;
2026-06-11 14:34:02 +00:00
assert! ( owned_chunks . len () >= 3 , "3 MiB must split into ≥3 chunks" );
let foreign = blake3 ::hash ( format! ( "foreign- {} " , Uuid ::new_v4 ()). as_bytes ())
. to_hex ()
. to_string ();
let orphan = blake3 ::hash ( format! ( "orphan- {} " , Uuid ::new_v4 ()). as_bytes ())
. to_hex ()
. to_string ();
2026-06-25 08:16:45 +02:00
// Stamp `orphaned_at = now()` on the ref-0 row so it sits inside the
// GC grace window for the duration of this test. Without it,
// `orphaned_at IS NULL` is treated by `garbage_collect` as
// "pre-migration, immediately reapable" — and any sibling test in
// the shared pool that calls `garbage_collect()` (e.g.
// `garbage_collect_respects_grace_and_cross_checks`) would race
// with the pin below and delete the row first.
2026-06-11 14:34:02 +00:00
sqlx ::query (
2026-06-25 08:16:45 +02:00
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at) VALUES
($1, 10, 1, NULL),
($2, 10, 0, now())" ,
2026-06-11 14:34:02 +00:00
)
. bind ( & foreign )
. bind ( & orphan )
. execute ( pool . as_ref ())
. await
. expect ( "seed foreign+orphan" );
let unknown = blake3 ::hash ( format! ( "unknown- {} " , Uuid ::new_v4 ()). as_bytes ())
. to_hex ()
. to_string ();
let mut probe : Vec < String > = owned_chunks . clone ();
probe . push ( foreign . clone ());
probe . push ( orphan . clone ());
probe . push ( unknown . clone ());
// claimable: only the owned chunks (advisory view — orphans are
// intentionally NOT advertised; the commit pin may still take them).
let claimable = svc . claimable_chunks ( user , & probe ). await . expect ( "claimable" );
for c in & owned_chunks {
assert! ( claimable . contains ( c ), "owned chunk {c} must be claimable" );
}
assert! (
! claimable . contains ( & foreign ),
"foreign chunk must not be claimable"
);
assert! (
! claimable . contains ( & unknown ),
"unknown chunk must not be claimable"
);
// pin: owned + orphan succeed; foreign and unknown are refused.
let pinned = svc . pin_claimable_chunks ( user , & probe ). await . expect ( "pin" );
for c in & owned_chunks {
assert! ( pinned . contains ( c ), "owned chunk {c} must pin" );
}
assert! (
pinned . contains ( & orphan ),
"ref-0 orphan must pin (just-uploaded state)"
);
assert! (
! pinned . contains ( & foreign ),
"foreign owned chunk must NOT pin"
);
assert! ( ! pinned . contains ( & unknown ), "unknown hash must NOT pin" );
// Ref counts moved exactly where they should.
assert_eq! ( blob_ref ( & pool , & orphan ). await , Some ( 1 ), "orphan 0→1" );
assert_eq! (
blob_ref ( & pool , & foreign ). await ,
Some ( 1 ),
"foreign untouched"
);
assert_eq! (
blob_ref ( & pool , & owned_chunks [ 0 ]). await ,
Some ( 2 ),
"owned chunk 1→2 (manifest + pin)"
);
// Release restores the original counts (clamped at 0).
let pinned_vec : Vec < String > = pinned . into_iter (). collect ();
svc . release_pinned_chunks ( & pinned_vec ). await ;
assert_eq! ( blob_ref ( & pool , & orphan ). await , Some ( 0 ));
assert_eq! ( blob_ref ( & pool , & owned_chunks [ 0 ]). await , Some ( 1 ));
cleanup ( & pool , & file_hash , file_id , & [ foreign , orphan ]). await ;
}
// ── Loose chunk store ────────────────────────────────────────
#[tokio::test]
async fn loose_chunks_register_as_orphans_without_touching_existing_refs () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
2026-06-18 13:29:41 +02:00
let ( user , drive_id ) = seed_user ( & pool ). await ;
2026-06-11 14:34:02 +00:00
// An owned chunk that the client redundantly re-uploads.
let data = content ( 100 * 1024 , 22 );
let ( file_hash , owned_chunks , file_id ) =
2026-06-18 13:29:41 +02:00
seed_owned_content ( & svc , & pool , user , drive_id , & data , "loose" ). await ;
2026-06-11 14:34:02 +00:00
let owned_chunk_bytes = {
let mut stream = svc . read_blob_stream ( & file_hash ). await . expect ( "stream" );
let mut out = Vec ::new ();
while let Some ( part ) = stream . next (). await {
out . extend_from_slice ( & part . expect ( "part" ));
}
out
};
let fresh = content ( 50 * 1024 , 23 );
let frames = stream ::iter ( vec! [
Ok ::< _ , DomainError > ( Bytes ::from ( fresh . clone ())),
Ok ( Bytes ::from ( fresh . clone ())), // duplicate frame
Ok ( Bytes ::from ( owned_chunk_bytes . clone ())), // already-referenced chunk
]);
let received = svc . store_loose_chunks ( frames ). await . expect ( "store loose" );
assert_eq! ( received . len (), 3 , "every frame is answered, in order" );
assert_eq! (
received [ 0 ]. 0 , received [ 1 ]. 0 ,
"duplicate frames share a hash"
);
let fresh_hash = received [ 0 ]. 0. clone ();
assert_eq! (
blob_ref ( & pool , & fresh_hash ). await ,
Some ( 0 ),
"fresh chunk lands as an unreferenced orphan"
);
assert_eq! (
blob_ref ( & pool , & owned_chunks [ 0 ]). await ,
Some ( 1 ),
"re-uploading an existing chunk must not disturb its refs"
);
// The orphan's bytes are really there and addressable.
assert_eq! (
svc . backend (). blob_exists ( & fresh_hash ). await . unwrap (),
true ,
"orphan chunk bytes must exist in the backend"
);
cleanup ( & pool , & file_hash , file_id , & [ fresh_hash ]). await ;
}
2026-06-19 09:37:51 +00:00
// ── Garbage collection: grace window + reference cross-checks ─
#[tokio::test]
async fn garbage_collect_honours_grace_window_and_references () {
2026-07-22 02:06:04 +02:00
let _gc_test_guard = GC_TEST_SERIALIZER . lock (). await ;
2026-06-19 09:37:51 +00:00
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
2026-06-19 16:59:35 +02:00
let ( user , drive_id ) = seed_user ( & pool ). await ;
2026-06-19 09:37:51 +00:00
// (A) An aged orphan (orphaned well past the grace window) with no
// references → must be collected (row + backing file).
// (B) A freshly orphaned blob (orphaned_at = now()) → must survive: a
// concurrent uploader could still be about to pin it.
let aged = blake3 ::hash ( format! ( "aged- {} " , Uuid ::new_v4 ()). as_bytes ())
. to_hex ()
. to_string ();
let fresh = blake3 ::hash ( format! ( "fresh- {} " , Uuid ::new_v4 ()). as_bytes ())
. to_hex ()
. to_string ();
for h in [ & aged , & fresh ] {
svc . backend ()
. put_blob_from_bytes_unsynced ( h , Bytes ::from_static ( b "xyz" ))
. await
. expect ( "write blob" );
}
svc . backend ()
. sync_blobs ( & [ aged . clone (), fresh . clone ()])
. await
. expect ( "sync" );
sqlx ::query (
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at) VALUES
($1, 3, 0, now() - interval '2 hours'),
($2, 3, 0, now())" ,
)
. bind ( & aged )
. bind ( & fresh )
. execute ( pool . as_ref ())
. await
. expect ( "seed orphans" );
// (C) A chunk still listed by a live file's manifest, but whose
// blobs.ref_count has drifted to 0 and aged past the grace window.
// The manifest cross-check must keep it (and its bytes) alive — a
// stale ref_count must never delete referenced content.
let data = content ( 3 * 1024 * 1024 , 71 );
let ( file_hash , owned_chunks , file_id ) =
2026-06-19 16:59:35 +02:00
seed_owned_content ( & svc , & pool , user , drive_id , & data , "gc" ). await ;
2026-06-19 09:37:51 +00:00
let referenced = owned_chunks [ 0 ]. clone ();
sqlx ::query (
"UPDATE storage.blobs
SET ref_count = 0, orphaned_at = now() - interval '2 hours'
WHERE hash = $1" ,
)
. bind ( & referenced )
. execute ( pool . as_ref ())
. await
. expect ( "drift referenced chunk" );
let ( deleted , _bytes ) = svc . garbage_collect (). await . expect ( "gc" );
assert! ( deleted >= 1 , "the aged orphan must be collected" );
// Aged orphan fully gone.
assert! (
blob_ref ( & pool , & aged ). await . is_none (),
"aged orphan row removed"
);
assert! (
! svc . backend (). blob_exists ( & aged ). await . unwrap (),
"aged orphan file unlinked"
);
// Fresh orphan preserved by the grace window.
assert_eq! (
blob_ref ( & pool , & fresh ). await ,
Some ( 0 ),
"fresh orphan survives the grace window"
);
assert! (
svc . backend (). blob_exists ( & fresh ). await . unwrap (),
"fresh orphan bytes kept"
);
// Referenced chunk preserved by the manifest cross-check despite ref 0.
assert_eq! (
blob_ref ( & pool , & referenced ). await ,
Some ( 0 ),
"referenced chunk row kept"
);
assert! (
svc . backend (). blob_exists ( & referenced ). await . unwrap (),
"referenced chunk bytes kept"
);
let _ = sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = ANY($1)" )
. bind ( vec! [ aged , fresh ])
. execute ( pool . as_ref ())
. await ;
cleanup ( & pool , & file_hash , file_id , & []). await ;
}
2026-07-22 02:06:04 +02:00
// ── Batched manifest GC: shared + repeated chunk accounting ───
#[tokio::test]
async fn garbage_collect_batches_shared_and_repeated_chunk_decrements () {
let _gc_test_guard = GC_TEST_SERIALIZER . lock (). await ;
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
let ( user , drive_id ) = seed_user ( & pool ). await ;
// A live CDC file supplies a chunk shared by two synthetic orphan
// manifests. Its file row keeps the live manifest out of phase 1.
let data = content ( 3 * 1024 * 1024 , 83 );
let ( live_hash , live_chunks , live_file_id ) =
seed_owned_content ( & svc , & pool , user , drive_id , & data , "gc-batch-live" ). await ;
let shared = live_chunks
. first ()
. expect ( "live content has chunks" )
. clone ();
let orphan_a = blake3 ::hash ( Uuid ::new_v4 (). as_bytes ()). to_hex (). to_string ();
let orphan_b = blake3 ::hash ( Uuid ::new_v4 (). as_bytes ()). to_hex (). to_string ();
let unique_a = blake3 ::hash ( Uuid ::new_v4 (). as_bytes ()). to_hex (). to_string ();
let unique_b = blake3 ::hash ( Uuid ::new_v4 (). as_bytes ()). to_hex (). to_string ();
// `shared` owns one reference from the live manifest plus one from
// each orphan manifest. Manifest A repeats it twice in its ordered
// chunk list, but ingest accounting owns only one DISTINCT reference
// per manifest — the batched decrement must therefore be 2, not 3.
sqlx ::query ( "UPDATE storage.blobs SET ref_count = ref_count + 2 WHERE hash = $1" )
. bind ( & shared )
. execute ( pool . as_ref ())
. await
. expect ( "add orphan refs to shared chunk" );
sqlx ::query (
"INSERT INTO storage.blobs (hash, size, ref_count)
VALUES ($1, 1, 1), ($2, 1, 1)" ,
)
. bind ( & unique_a )
. bind ( & unique_b )
. execute ( pool . as_ref ())
. await
. expect ( "seed unique orphan chunks" );
sqlx ::query (
"INSERT INTO storage.chunk_manifests
(file_hash, chunk_hashes, chunk_sizes, total_size,
chunk_count, content_type, ref_count)
VALUES
($1, $2, $3, 3, 3, 'application/octet-stream', 0),
($4, $5, $6, 2, 2, 'application/octet-stream', 0)" ,
)
. bind ( & orphan_a )
. bind ( vec! [ shared . clone (), shared . clone (), unique_a . clone ()])
. bind ( vec! [ 1 i64 , 1 , 1 ])
. bind ( & orphan_b )
. bind ( vec! [ shared . clone (), unique_b . clone ()])
. bind ( vec! [ 1 i64 , 1 ])
. execute ( pool . as_ref ())
. await
. expect ( "seed orphan manifests" );
svc . garbage_collect (). await . expect ( "batched GC" );
let remaining_orphans : i64 = sqlx ::query_scalar (
"SELECT COUNT(*) FROM storage.chunk_manifests
WHERE file_hash = ANY($1)" ,
)
. bind ( vec! [ orphan_a , orphan_b ])
. fetch_one ( pool . as_ref ())
. await
. expect ( "orphan manifest count" );
assert_eq! ( remaining_orphans , 0 , "both orphan manifests removed" );
let live_manifest_exists : bool = sqlx ::query_scalar (
"SELECT EXISTS(
SELECT 1 FROM storage.chunk_manifests WHERE file_hash = $1
)" ,
)
. bind ( & live_hash )
. fetch_one ( pool . as_ref ())
. await
. expect ( "live manifest lookup" );
assert! ( live_manifest_exists , "file-backed live manifest preserved" );
assert_eq! (
blob_ref ( & pool , & shared ). await ,
Some ( 1 ),
"shared chunk decremented once per orphan manifest, not per occurrence"
);
assert_eq! ( blob_ref ( & pool , & unique_a ). await , Some ( 0 ));
assert_eq! ( blob_ref ( & pool , & unique_b ). await , Some ( 0 ));
let stamped : i64 = sqlx ::query_scalar (
"SELECT COUNT(*) FROM storage.blobs
WHERE hash = ANY($1) AND orphaned_at IS NOT NULL" ,
)
. bind ( vec! [ unique_a . clone (), unique_b . clone ()])
. fetch_one ( pool . as_ref ())
. await
. expect ( "orphan stamps" );
assert_eq! ( stamped , 2 , "newly orphaned chunks start their GC grace" );
cleanup ( & pool , & live_hash , live_file_id , & [ unique_a , unique_b ]). await ;
}
2026-06-19 09:58:51 +00:00
// ── Manifest dereference defers chunk reclamation to GC ──────
#[tokio::test]
async fn manifest_dereference_defers_chunk_reclamation_to_gc () {
2026-07-22 02:06:04 +02:00
let _gc_test_guard = GC_TEST_SERIALIZER . lock (). await ;
2026-06-19 09:58:51 +00:00
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
2026-06-19 16:59:35 +02:00
let ( user , drive_id ) = seed_user ( & pool ). await ;
2026-06-19 09:58:51 +00:00
// Single-owner multi-chunk CDC file → its chunks are uniquely owned.
let data = content ( 3 * 1024 * 1024 , 91 );
let ( file_hash , chunks , file_id ) =
2026-06-19 16:59:35 +02:00
seed_owned_content ( & svc , & pool , user , drive_id , & data , "deref" ). await ;
2026-06-19 09:58:51 +00:00
assert! ( chunks . len () >= 3 , "3 MiB must split into ≥3 chunks" );
// The delete_file_permanently sequence: drop the file row (PG trigger)
// then dereference the manifest.
sqlx ::query ( "DELETE FROM storage.files WHERE id = $1" )
. bind ( file_id )
. execute ( pool . as_ref ())
. await
. expect ( "delete file row" );
assert! (
svc . remove_reference ( & file_hash ). await . expect ( "deref" ),
"last reference removed"
);
// Manifest is gone immediately…
let manifest_rc : Option < i32 > = sqlx ::query_scalar (
"SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( & file_hash )
. fetch_optional ( pool . as_ref ())
. await
. expect ( "manifest query" );
assert! ( manifest_rc . is_none (), "manifest deleted" );
// …but the chunk rows + bytes survive at ref_count 0: no inline unlink
// that could race a concurrent re-upload of the same chunk.
for c in & chunks {
assert_eq! (
blob_ref ( & pool , c ). await ,
Some ( 0 ),
"chunk dereferenced, not yet deleted"
);
assert! (
svc . backend (). blob_exists ( c ). await . unwrap (),
"chunk bytes kept until GC reclaims them"
);
}
// Age the orphans past the grace window; GC then reclaims rows + files.
sqlx ::query (
"UPDATE storage.blobs SET orphaned_at = now() - interval '2 hours' WHERE hash = ANY($1)" ,
)
. bind ( & chunks )
. execute ( pool . as_ref ())
. await
. expect ( "age orphans" );
svc . garbage_collect (). await . expect ( "gc" );
for c in & chunks {
assert! ( blob_ref ( & pool , c ). await . is_none (), "chunk row reclaimed" );
assert! (
! svc . backend (). blob_exists ( c ). await . unwrap (),
"chunk file reclaimed"
);
}
cleanup ( & pool , & file_hash , file_id , & []). await ;
}
2026-06-11 14:34:02 +00:00
// ── Verification read ────────────────────────────────────────
#[tokio::test]
async fn hash_chunk_sequence_recomputes_and_validates_sizes () {
let pool = test_pool (). await ;
let dir = TempDir ::new (). unwrap ();
let svc = local_svc ( & pool , & dir ). await ;
2026-06-18 13:29:41 +02:00
let ( user , drive_id ) = seed_user ( & pool ). await ;
2026-06-11 14:34:02 +00:00
let data = content ( 2 * 1024 * 1024 + 137 , 24 );
let ( file_hash , _chunks , file_id ) =
2026-06-18 13:29:41 +02:00
seed_owned_content ( & svc , & pool , user , drive_id , & data , "verify" ). await ;
2026-06-11 14:34:02 +00:00
let manifest : ( Vec < String > , Vec < i64 > ) = sqlx ::query_as (
"SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( & file_hash )
. fetch_one ( pool . as_ref ())
. await
. expect ( "manifest" );
let sequence : Vec < ( String , u64 ) > = manifest
. 0
. iter ()
. cloned ()
. zip ( manifest . 1. iter (). map ( | s | * s as u64 ))
. collect ();
let ( computed , head ) = svc
2026-07-19 19:33:41 +00:00
. hash_chunk_sequence ( sequence . clone (), 16 )
2026-06-11 14:34:02 +00:00
. await
. expect ( "verification read" );
assert_eq! ( computed , file_hash , "recomputed hash must match" );
assert_eq! (
& head [ .. ],
& data [ .. 16 ],
"sniff head must be the file's first bytes"
);
// A wrong declared size must be rejected — Range arithmetic
// depends on manifest sizes being true.
let mut lying = sequence . clone ();
lying [ 0 ]. 1 += 1 ;
assert! (
2026-07-19 19:33:41 +00:00
svc . hash_chunk_sequence ( lying , 0 ). await . is_err (),
2026-06-11 14:34:02 +00:00
"size lie must fail verification"
);
cleanup ( & pool , & file_hash , file_id , & []). await ;
}
}
2026-09-03 21:18:34 +02:00
// ─────────────────────────────────────────────────────────────────────────────
// Who decides a manifest is dead: the counter, or the reference registry?
//
2026-09-04 12:30:12 +02:00
// **The registry, and only the registry.** `manifest_reap_sql` asks
// `WHERE <no registered source references it>` and does not mention
// `ref_count` at all.
2026-09-03 21:18:34 +02:00
//
2026-09-04 12:30:12 +02:00
// It used to read `ref_count <= 0 OR <unreferenced>`. Each arm covered a
// real deletion path — the single-file path decrements the counter via
// `cleanup_if_orphaned`, bulk paths (user cascade, empty_trash) only fire the
// `storage.blobs` trigger — so the disjunction looked like belt and braces.
// It was the opposite: with OR, either signal alone deletes, so a counter
// that under-reported made live content collectible and the registry that
// knew better was never consulted.
2026-09-03 21:18:34 +02:00
//
2026-09-04 12:30:12 +02:00
// Not hypothetical. `storage.copy_folder_tree` used to take references with
// `UPDATE storage.blobs … WHERE hash = blob_hash`, which matches nothing for
// a CDC file — whose `blob_hash` names a manifest, not a chunk — so it took
// no reference at all. Copy a folder, delete the original, and the copy's
// bytes were reaped. Both copy paths now go through
// `storage.add_blob_references`, but that fix relied on getting the counter
// right, and there are two implementations of the reference contract
2026-09-03 21:18:34 +02:00
// (`storage.add_blob_references` in SQL, `DedupService::add_reference` in
2026-09-04 12:30:12 +02:00
// Rust) that must agree forever. Removing the counter's authority is what
// makes a future disagreement a leak rather than data loss.
2026-09-03 21:18:34 +02:00
//
2026-09-04 12:30:12 +02:00
// The two tests pin both directions, and they are only meaningful together:
2026-09-03 21:18:34 +02:00
//
2026-09-04 12:30:12 +02:00
// * `gc_spares_a_manifest_with_a_live_referrer` — a wrong-LOW counter must
// not delete. This is the fix.
// * `gc_reaps_an_unreferenced_manifest_despite_a_high_refcount` — a
// wrong-HIGH counter must not veto. This is the coverage the removed arm
// used to provide, and dropping it must not have traded one failure for
// the other.
2026-09-03 21:18:34 +02:00
//
2026-09-04 12:30:12 +02:00
// See `docs/plan/derived-blobs.md`. Gated on `--cfg integration_tests` like
// the other PG suites.
2026-09-03 21:18:34 +02:00
// ─────────────────────────────────────────────────────────────────────────────
// `allow(dead_code)`: the module is gated on a cfg flag, not on `test`, so a
// plain `cargo build --cfg integration_tests` compiles the helpers while
// `#[tokio::test]` drops their only callers. Same reason the rechunk suite
// above carries it.
#[cfg(integration_tests)]
#[allow(dead_code)]
mod gc_reference_authority_integration_tests {
use super ::* ;
use crate ::infrastructure ::services ::local_blob_backend ::LocalBlobBackend ;
use crate ::integration_test_support ::{ ensure_clean_test_db , test_db_url };
use sqlx ::Row ;
use sqlx ::postgres ::PgPoolOptions ;
use tempfile ::TempDir ;
use uuid ::Uuid ;
async fn test_pool () -> Arc < PgPool > {
let pool = PgPoolOptions ::new ()
. max_connections ( 4 )
. connect ( & test_db_url ())
. await
. expect ( "connect to test DB — run tests/common/spawn-db.sh first" );
ensure_clean_test_db ( & pool ). await ;
Arc ::new ( pool )
}
async fn seed_user ( pool : & PgPool ) -> Uuid {
sqlx ::query ( "SELECT d.id AS drive_id FROM storage.drives d WHERE d.default_for_user IS NOT NULL LIMIT 1" )
. fetch_one ( pool )
. await
. map ( | r | r . get ::< Uuid , _ > ( "drive_id" ))
. expect ( "storage.drives must be seeded (init-test-schema.sh)" )
}
async fn local_svc ( pool : & Arc < PgPool > , dir : & TempDir ) -> DedupService {
let backend = Arc ::new ( LocalBlobBackend ::new ( & dir . path (). join ( "blobs" )));
backend . initialize (). await . expect ( "init backend" );
DedupService ::new ( backend , pool . clone (), pool . clone ())
}
/// Unique, poorly-compressible content of `len` bytes. The random tail
/// keeps every invocation's hash distinct, so rows left behind by a
/// panicking run can never collide with the current one.
fn content ( len : usize ) -> Vec < u8 > {
let mut data : Vec < u8 > = ( 0 .. len )
. map ( | i | (( i % 251 ) as u8 ). wrapping_add (( i / 7919 ) as u8 ))
. collect ();
data . extend_from_slice ( Uuid ::new_v4 (). as_bytes ());
data
}
/// A stored CDC blob plus a live `storage.files` row referencing it.
///
/// The file row is inserted BEFORE the store, deliberately: phase 1 of
/// `garbage_collect` reaps manifests no source references, so with the
/// opposite order a concurrent GC from another test could reap ours in
/// the window between the two statements. BLAKE3 is deterministic, so
/// the hash is known in advance and the order costs nothing.
///
/// Returns `(file_hash, chunk_hashes, file_id)`.
async fn seed_referenced_cdc_blob (
svc : & DedupService ,
pool : & PgPool ,
drive_id : Uuid ,
data : & [ u8 ],
label : & str ,
) -> ( String , Vec < String > , Uuid ) {
let file_hash = blake3 ::hash ( data ). to_hex (). to_string ();
let file_id : Uuid = sqlx ::query_scalar (
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id" ,
)
. bind ( format! (
"rust-test-gcauth- {label} - {} " ,
& Uuid ::new_v4 (). to_string ()[ .. 8 ]
))
. bind ( drive_id )
. bind ( & file_hash )
. bind ( data . len () as i64 )
. fetch_one ( pool )
. await
. expect ( "file row" );
let source = stream ::iter ( vec! [ Ok ::< _ , std ::io ::Error > ( Bytes ::copy_from_slice ( data ))]);
let stored = svc
. store_from_stream ( source , Some ( "application/octet-stream" . into ()))
. await
. expect ( "store" );
assert_eq! (
stored . hash (),
file_hash ,
"pre-computed BLAKE3 must match CDC-store output"
);
let chunks : Vec < String > = sqlx ::query_scalar (
"SELECT UNNEST(chunk_hashes) FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( & file_hash )
. fetch_all ( pool )
. await
. expect ( "chunks" );
// Fixture premise. A single-chunk blob has `file_hash == chunk_hash`
// (both BLAKE3 over the same bytes), which is the aliasing case the
// reference contract carries a `NOT EXISTS` guard for. This suite is
// about the multi-chunk shape — the one the copy bug broke, where
// `blob_hash` names a manifest that `storage.blobs` has no row for —
// so assert we actually got it rather than silently testing the easy
// case if CDC parameters change.
assert! (
chunks . len () > 1 ,
"fixture must be multi-chunk to exercise the manifest level, got {} \
chunk(s) for {} bytes (CDC_AVG_CHUNK = {CDC_AVG_CHUNK})" ,
chunks . len (),
data . len ()
);
( file_hash , chunks , file_id )
}
async fn manifest_exists ( pool : & PgPool , file_hash : & str ) -> bool {
sqlx ::query_scalar ::< _ , i64 > (
"SELECT COUNT(*) FROM storage.chunk_manifests WHERE file_hash = $1" ,
)
. bind ( file_hash )
. fetch_one ( pool )
. await
. expect ( "count manifests" )
> 0
}
/// Simulate a reference that was never taken: the file row is live, the
/// counter says nothing needs the content. Exactly the state the
/// `copy_folder_tree` bug produced, and the state any future divergence
/// between the SQL and Rust reference contracts would produce.
async fn force_zero_manifest_refcount ( pool : & PgPool , file_hash : & str ) {
let updated =
sqlx ::query ( "UPDATE storage.chunk_manifests SET ref_count = 0 WHERE file_hash = $1" )
. bind ( file_hash )
. execute ( pool )
. await
. expect ( "zero the manifest refcount" )
. rows_affected ();
assert_eq! ( updated , 1 , "expected exactly one manifest for {file_hash}" );
}
async fn cleanup ( pool : & PgPool , file_hash : & str , file_id : Uuid , chunks : & [ String ]) {
let _ = sqlx ::query ( "DELETE FROM storage.files WHERE id = $1" )
. bind ( file_id )
. execute ( pool )
. await ;
let _ = sqlx ::query (
"DELETE FROM storage.files
WHERE blob_hash = $1 AND name LIKE 'rust-test-gcauth-%'" ,
)
. bind ( file_hash )
. execute ( pool )
. await ;
let _ = sqlx ::query ( "DELETE FROM storage.chunk_manifests WHERE file_hash = $1" )
. bind ( file_hash )
. execute ( pool )
. await ;
let mut to_drop = chunks . to_vec ();
to_drop . push ( file_hash . to_string ());
let _ = sqlx ::query ( "DELETE FROM storage.blobs WHERE hash = ANY($1)" )
. bind ( & to_drop )
. execute ( pool )
. await ;
}
2026-09-04 12:30:12 +02:00
/// The coverage that dropping the `ref_count` arm had to preserve.
2026-09-03 21:18:34 +02:00
///
2026-09-04 12:30:12 +02:00
/// Bulk-delete paths (user cascade, `empty_trash`) remove
/// `storage.files` rows via a trigger that only touches `storage.blobs`,
/// so the manifest's counter is left **stuck high** with no referrers.
/// Under the old `OR` predicate the registry arm collected those. Now
/// that the registry is the sole authority it still does — a high counter
/// no longer keeps dead content alive, just as a zero one no longer kills
/// live content.
///
/// This is the direction the counter can still be wrong in, and it is the
/// benign one: a leak, detected by the refcount recompute, not data loss.
2026-09-03 21:18:34 +02:00
#[tokio::test]
2026-09-04 12:30:12 +02:00
async fn gc_reaps_an_unreferenced_manifest_despite_a_high_refcount () {
let _gc_test_guard = GC_TEST_SERIALIZER . lock (). await ;
2026-09-03 21:18:34 +02:00
let pool = test_pool (). await ;
let drive_id = seed_user ( & pool ). await ;
let dir = TempDir ::new (). expect ( "tempdir" );
let svc = local_svc ( & pool , & dir ). await ;
let data = content ( 2 * 1024 * 1024 );
let ( file_hash , chunks , file_id ) =
2026-09-04 12:30:12 +02:00
seed_referenced_cdc_blob ( & svc , & pool , drive_id , & data , "stuckhigh" ). await ;
2026-09-03 21:18:34 +02:00
2026-09-04 12:30:12 +02:00
// Simulate the bulk path: referrer gone, counter untouched.
sqlx ::query ( "DELETE FROM storage.files WHERE id = $1" )
. bind ( file_id )
. execute ( pool . as_ref ())
. await
. expect ( "drop the referrer" );
let bumped =
sqlx ::query ( "UPDATE storage.chunk_manifests SET ref_count = 7 WHERE file_hash = $1" )
. bind ( & file_hash )
. execute ( pool . as_ref ())
. await
. expect ( "inflate the refcount" )
. rows_affected ();
assert_eq! ( bumped , 1 , "expected exactly one manifest for {file_hash}" );
// Plain GC, NOT `garbage_collect_force`. Phase 1 has no time filter —
// the manifest predicate is purely "is it referenced" — so the grace
// window is irrelevant to what these tests assert. Forcing it would
// bypass the CHUNK-level grace for the whole shared test database and
// reap sibling tests' just-uploaded orphans; that is exactly how this
// suite first broke `claim_and_pin_respect_ownership_and_orphans`.
svc . garbage_collect (). await . expect ( "gc" );
2026-09-03 21:18:34 +02:00
let survived = manifest_exists ( & pool , & file_hash ). await ;
cleanup ( & pool , & file_hash , file_id , & chunks ). await ;
assert! (
! survived ,
2026-09-04 12:30:12 +02:00
"GC left a manifest nothing references, because its ref_count was \
above zero. Removing the `ref_count <= 0` arm must not have made \
the counter able to VETO collection either — the registry is the \
authority in both directions."
2026-09-03 21:18:34 +02:00
);
}
2026-09-04 12:30:12 +02:00
/// **The contract.** A manifest with a live `storage.files` referrer
/// survives GC no matter what its counter says.
2026-09-03 21:18:34 +02:00
///
2026-09-04 12:30:12 +02:00
/// This failed until `manifest_reap_sql` dropped its `ref_count <= 0`
/// arm. The counter was a second, independent licence to delete, so a
/// reference that was never taken — the `copy_folder_tree` bug — destroyed
/// the copy's content rather than merely mis-reporting a number.
2026-09-03 21:18:34 +02:00
#[tokio::test]
async fn gc_spares_a_manifest_with_a_live_referrer () {
2026-09-04 12:30:12 +02:00
let _gc_test_guard = GC_TEST_SERIALIZER . lock (). await ;
2026-09-03 21:18:34 +02:00
let pool = test_pool (). await ;
let drive_id = seed_user ( & pool ). await ;
let dir = TempDir ::new (). expect ( "tempdir" );
let svc = local_svc ( & pool , & dir ). await ;
let data = content ( 2 * 1024 * 1024 );
let ( file_hash , chunks , file_id ) =
seed_referenced_cdc_blob ( & svc , & pool , drive_id , & data , "spare" ). await ;
force_zero_manifest_refcount ( & pool , & file_hash ). await ;
// The file row is still there — this is the whole premise, so assert
// it rather than trusting that nothing else reaped it concurrently.
let referrers : i64 = sqlx ::query_scalar ( "SELECT COUNT(*) FROM storage.files WHERE id = $1" )
. bind ( file_id )
. fetch_one ( pool . as_ref ())
. await
. expect ( "count referrers" );
assert_eq! (
referrers , 1 ,
"fixture file row must still reference the blob"
);
2026-09-04 12:30:12 +02:00
// Plain GC — see the sibling test for why `force` is wrong here.
svc . garbage_collect (). await . expect ( "gc" );
2026-09-03 21:18:34 +02:00
let survived = manifest_exists ( & pool , & file_hash ). await ;
let readable = svc . read_blob_stream ( & file_hash ). await . is_ok ();
cleanup ( & pool , & file_hash , file_id , & chunks ). await ;
assert! (
survived ,
"GC reaped a manifest that storage.files still references. \
2026-09-04 12:30:12 +02:00
ref_count was 0 and something let that alone decide — check \
whether `manifest_reap_sql` has regained a `ref_count` clause. \
FilesReferenceSource is registered and knows the row is live; it \
must be the only authority on collectibility."
2026-09-03 21:18:34 +02:00
);
assert! (
readable ,
"manifest survived but its content is unreadable — chunk-level \
reclamation followed the same zero counter"
);
}
}