Re-chunk pre-CDC legacy blobs into CDC manifests at startup

Files uploaded before chunk_manifests landed (20260414000000) are stored
as ONE whole-file blob with no manifest. Every legacy fallback in
DedupService exists to serve them, and the cost concentrates on Range
reads: with encryption enabled, seeking inside a legacy video decrypts
the ENTIRE blob (AES-GCM is all-or-nothing), where a CDC file decrypts
only the overlapping <=1 MiB chunks.

This adds a one-time, idempotent background migration (spawned from the
composition root after dedup init, maintenance pool) that converts each
legacy blob into a regular CDC file, indistinguishable from a native
upload:

  1. Spool the blob through the normal read path (decrypts when
     encryption is on) to a per-attempt-unique temp file, verifying
     BLAKE3 == hash; sizes come from the verified spool, never from the
     legacy storage.blobs.size column (the manifest's total_size drives
     Range arithmetic).
  2. CDC-chunk + store chunks via the existing store_chunks (one
     manifest reference per distinct chunk).
  3. One short accounting TX with the blob row locked: manifest INSERT
     with ref_count = N current file references, blob ref_count -= N,
     row deleted only at exactly 0 - so single-chunk files (chunk hash
     == file hash) keep the physical blob, which IS the chunk; only
     bookkeeping moves, no bytes are rewritten.
  4. Physical whole-file blob deleted only when its row dropped.

Races lean on the row lock: a concurrent identical upload landing a
legacy reference after commit keeps the blob row alive and that file
readable via the fallback (bounded space leak, never data loss); a
crash between chunk store and the TX over-counts one file's chunk refs
(also a bounded leak). Corrupt blobs (content != hash) are logged,
counted, excluded from the sweep and left untouched, with a hard cap
before aborting.

Per-hash failures never block the sweep; manifests are the resumability
marker, so a restart continues where it left off. The legacy read/write
fallbacks stay in place as the safety net while a deployment converges;
they can be deleted once fleets report "legacy re-chunk: nothing to do".

Opt-out via OXICLOUD_LEGACY_RECHUNK=false (documented in example.env)
for metered remote backends where the one-time re-read should be
scheduled deliberately.

Covered by five integration tests against real PostgreSQL (multi-chunk
accounting + Range across a chunk boundary, single-chunk physical-blob
preservation, corrupt-blob isolation, empty blob, and the full
encrypted-backend roundtrip); they run concurrently, which also
exercises the cross-sweep race handling.

https://claude.ai/code/session_0193Hff42gaA962wThxMGSd1
This commit is contained in:
Claude
2026-06-11 10:43:45 +00:00
parent ecbdaee19e
commit 54c494419c
4 changed files with 783 additions and 0 deletions
+9
View File
@@ -87,6 +87,15 @@ OXICLOUD_SERVER_HOST=127.0.0.1
# fresher sync detection, higher = fewer background UPDATEs. Minimum: 100. # fresher sync detection, higher = fewer background UPDATEs. Minimum: 100.
#OXICLOUD_TREE_ETAG_FLUSH_MS=500 #OXICLOUD_TREE_ETAG_FLUSH_MS=500
# One-time startup migration that converts pre-CDC whole-file blobs (files
# uploaded before chunked dedup landed) into CDC chunk manifests, in the
# background on the maintenance pool. Fixes the legacy penalty where a Range
# read (video seek) reads — and with encryption, DECRYPTS — the entire blob.
# Idempotent; a no-op once no legacy blobs remain. Disable only on metered
# remote backends (S3/Azure egress) where the one-time re-read of every
# legacy blob should be scheduled deliberately, e.g. off-peak.
#OXICLOUD_LEGACY_RECHUNK=true
# Allow multiple processes to bind to the same port (SO_REUSEPORT). # Allow multiple processes to bind to the same port (SO_REUSEPORT).
# DISABLED by default — leaving this off means a second accidental instance # DISABLED by default — leaving this off means a second accidental instance
# will fail immediately with "address already in use", which is the safe behaviour. # will fail immediately with "address already in use", which is the safe behaviour.
+15
View File
@@ -254,6 +254,14 @@ pub struct StorageConfig {
/// bound on how stale an ancestor folder's ETag can be after a change. /// bound on how stale an ancestor folder's ETag can be after a change.
/// Default: 500. Env: `OXICLOUD_TREE_ETAG_FLUSH_MS`. /// Default: 500. Env: `OXICLOUD_TREE_ETAG_FLUSH_MS`.
pub tree_etag_flush_ms: u64, pub tree_etag_flush_ms: u64,
/// Startup background migration that re-chunks legacy whole-file blobs
/// (written before CDC chunking landed) into chunk manifests, so Range
/// reads stop paying a full-blob read — and, with encryption enabled, a
/// full-blob decrypt. Idempotent and incremental; a no-op (one COUNT
/// query) once no legacy blobs remain. Disable on metered remote
/// backends where the one-time re-read of every legacy blob should be
/// scheduled deliberately. Default: true. Env: `OXICLOUD_LEGACY_RECHUNK`.
pub legacy_rechunk_enabled: bool,
/// Which blob storage backend to use (`local`, `s3`, or `azure`). /// Which blob storage backend to use (`local`, `s3`, or `azure`).
pub backend: StorageBackendType, pub backend: StorageBackendType,
/// S3-compatible backend configuration (used when `backend == S3`). /// S3-compatible backend configuration (used when `backend == S3`).
@@ -397,6 +405,7 @@ impl Default for StorageConfig {
chunk_dir: None, chunk_dir: None,
usage_reconcile_secs: 600, // 10 minutes usage_reconcile_secs: 600, // 10 minutes
tree_etag_flush_ms: 500, tree_etag_flush_ms: 500,
legacy_rechunk_enabled: true,
backend: StorageBackendType::Local, backend: StorageBackendType::Local,
s3: None, s3: None,
azure: None, azure: None,
@@ -1305,6 +1314,12 @@ impl AppConfig {
config.storage.tree_etag_flush_ms = val; config.storage.tree_etag_flush_ms = val;
} }
// Legacy whole-file blob re-chunk migration (startup background task)
if let Ok(enabled) = env::var("OXICLOUD_LEGACY_RECHUNK") {
config.storage.legacy_rechunk_enabled =
enabled.eq_ignore_ascii_case("true") || enabled == "1";
}
// Storage backend selection // Storage backend selection
if let Ok(backend) = env::var("OXICLOUD_STORAGE_BACKEND") { if let Ok(backend) = env::var("OXICLOUD_STORAGE_BACKEND") {
match backend.to_lowercase().as_str() { match backend.to_lowercase().as_str() {
+12
View File
@@ -319,6 +319,18 @@ impl AppServiceFactory {
); );
dedup_service.initialize().await?; dedup_service.initialize().await?;
// One-time background migration: re-chunk pre-CDC whole-file blobs
// into chunk manifests so Range reads (and, with encryption, partial
// decrypts) stop paying for the entire blob. No-op once converged.
if self.config.storage.legacy_rechunk_enabled {
dedup_service.spawn_legacy_rechunk();
} else {
tracing::info!(
"Legacy re-chunk migration disabled (OXICLOUD_LEGACY_RECHUNK=false) — \
pre-CDC whole-file blobs, if any, will keep using the legacy read path"
);
}
tracing::info!( tracing::info!(
"Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)" "Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)"
); );
@@ -1515,6 +1515,401 @@ impl DedupService {
Ok((total_deleted, total_bytes)) Ok((total_deleted, total_bytes))
} }
// ── 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:
// 1. Spool the blob to a temp file via the normal read path (this
// decrypts it when encryption is on), verifying BLAKE3 == hash.
// 2. CDC-chunk the spool + store chunks (`store_chunks` bumps each
// distinct chunk once — the manifest's reference).
// 3. One short accounting TX with the blob row locked:
// 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.
// 4. Physically delete the whole-file blob only when its row was
// 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
// space leak, never data loss. A crash between step 2 and 3 leaks one
// +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> {
// ── 1. Spool + verify (decrypts via the normal read path) ──
// The spooled, hash-verified plaintext is the source of truth for
// sizes — `storage.blobs.size` is legacy metadata we don't trust
// for the manifest's Range arithmetic.
//
// The path carries a per-attempt UUID: two processes sharing a temp
// dir and racing on the same hash must never truncate or delete each
// other's in-flight spool.
let spool = std::env::temp_dir().join(format!(
"oxicloud-rechunk-{}-{}.tmp",
&hash[..hash.len().min(16)],
uuid::Uuid::new_v4()
));
let result = self.spool_and_chunk(hash, &spool).await;
let _ = fs::remove_file(&spool).await;
let (chunk_hashes, chunk_sizes) = result?;
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
// content was re-uploaded and fully stored during our spool.
// Their bookkeeping is already correct; drop ours.
tx.rollback().await.ok();
self.release_chunk_refs(&chunk_hashes).await;
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)
}
/// Spool a legacy blob to `spool`, verify its BLAKE3 matches `hash`,
/// CDC-chunk it and store the chunks. Returns (chunk_hashes, chunk_sizes).
async fn spool_and_chunk(
&self,
hash: &str,
spool: &Path,
) -> Result<(Vec<String>, Vec<u64>), DomainError> {
use tokio::io::AsyncWriteExt;
let mut stream = self.read_blob_stream(hash).await?;
let file = fs::File::create(spool)
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Rechunk spool: {e}")))?;
let mut writer = tokio::io::BufWriter::with_capacity(512 * 1024, file);
let mut hasher = blake3::Hasher::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk
.map_err(|e| DomainError::internal_error("Dedup", format!("Rechunk read: {e}")))?;
hasher.update(&chunk);
writer
.write_all(&chunk)
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Rechunk write: {e}")))?;
}
writer
.flush()
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Rechunk flush: {e}")))?;
let actual = hasher.finalize().to_hex().to_string();
if actual != hash {
return Err(DomainError::internal_error(
"Dedup",
format!("Blob content does not match its hash (expected {hash}, got {actual})"),
));
}
// Empty blobs can't be mmap'd by the CDC analyser; they become an
// empty manifest (the chunked read path streams zero chunks).
let spooled_len = fs::metadata(spool)
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Rechunk stat: {e}")))?
.len();
if spooled_len == 0 {
return Ok((Vec::new(), Vec::new()));
}
let chunks = Self::cdc_chunk_file(spool)
.await
.map_err(DomainError::from)?;
self.store_chunks(spool, &chunks).await
}
/// Best-effort compensation: drop the per-manifest chunk references
/// taken by `store_chunks` when the manifest insert was abandoned.
async fn release_chunk_refs(&self, chunk_hashes: &[String]) {
if chunk_hashes.is_empty() {
return;
}
if let Err(e) = sqlx::query(
"UPDATE storage.blobs SET ref_count = GREATEST(ref_count - 1, 0)
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
.execute(self.maintenance_pool.as_ref())
.await
{
tracing::warn!("Legacy re-chunk: failed to release chunk refs: {e}");
}
}
}
/// 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,
} }
// ─── Port implementation ───────────────────────────────────────────────────── // ─── Port implementation ─────────────────────────────────────────────────────
@@ -1937,3 +2332,355 @@ mod tests {
); );
} }
} }
// ─────────────────────────────────────────────────────────────────────────────
// 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)
}
async fn seed_user(pool: &PgPool) -> Uuid {
sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.map(|r| r.get::<Uuid, _>("id"))
.expect("auth.users must be seeded (init-test-schema.sh)")
}
/// 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();
let backend = Arc::new(EncryptedBlobBackend::new(inner, &key));
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");
let user_id = seed_user(pool).await;
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]
);
let id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(&name)
.bind(user_id)
.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;
}
}