Optimize storage, GC, and upload hot paths

This commit is contained in:
DioCrafts
2026-07-22 02:06:04 +02:00
parent 67fe944c2a
commit d66956824c
68 changed files with 16500 additions and 108 deletions
@@ -299,7 +299,7 @@ impl BlobStorageBackend for CachedBlobBackend {
.map_err(|e| {
DomainError::internal_error("BlobCache", format!("seek: {e}"))
})?;
let take_len = end.map(|e| e - start + 1).unwrap_or(u64::MAX);
let take_len = end.map(|e| e.saturating_sub(start)).unwrap_or(u64::MAX);
let limited = file.take(take_len);
let stream: BlobStream =
Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE));
@@ -319,7 +319,7 @@ impl BlobStorageBackend for CachedBlobBackend {
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("seek: {e}")))?;
let take_len = end.map(|e| e - start + 1).unwrap_or(u64::MAX);
let take_len = end.map(|e| e.saturating_sub(start)).unwrap_or(u64::MAX);
let limited = file.take(take_len);
let stream: BlobStream =
Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE));
@@ -539,3 +539,61 @@ impl CachedBlobBackend {
Ok(dest)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
use futures::StreamExt;
async fn read_range(
backend: &dyn BlobStorageBackend,
hash: &str,
start: u64,
end: Option<u64>,
) -> Vec<u8> {
let mut stream = backend
.get_blob_range_stream(hash, start, end)
.await
.expect("open range stream");
let mut output = Vec::new();
while let Some(chunk) = stream.next().await {
output.extend_from_slice(&chunk.expect("read range chunk"));
}
output
}
#[tokio::test]
async fn range_end_is_exclusive_on_cold_and_hot_cache_reads() {
let data = Bytes::from_static(b"abcdef");
let hash = blake3::hash(&data).to_hex().to_string();
let inner_root = tempfile::tempdir().expect("inner tempdir");
let inner = Arc::new(LocalBlobBackend::new(inner_root.path()));
inner.initialize().await.expect("initialize inner");
inner
.put_blob_from_bytes(&hash, data)
.await
.expect("seed inner");
let cache_root = tempfile::tempdir().expect("cache tempdir");
let cached = CachedBlobBackend::new(
inner.clone(),
&BlobCacheConfig {
cache_dir: cache_root.path().to_path_buf(),
max_cache_bytes: 1024 * 1024,
},
);
cached.initialize().await.expect("initialize cache");
// Cold read fills the cache and must honor the exclusive end.
assert_eq!(read_range(&cached, &hash, 0, Some(1)).await, b"a");
assert!(cached.local_blob_path(&hash).is_some());
// Remove the origin so every remaining assertion proves a hot-cache read.
inner.delete_blob(&hash).await.expect("remove origin");
assert_eq!(read_range(&cached, &hash, 1, Some(3)).await, b"bc");
assert!(read_range(&cached, &hash, 3, Some(3)).await.is_empty());
assert_eq!(read_range(&cached, &hash, 2, None).await, b"cdef");
}
}
+504 -43
View File
@@ -48,7 +48,7 @@ use futures::stream::{self, StreamExt};
use futures::{Stream, TryStreamExt};
use sqlx::PgPool;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -288,6 +288,142 @@ pub struct ChunkManifest {
pub total_size: i64,
}
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 = 0usize;
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 = 0usize;
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 }
}
pub struct DedupService {
/// Pluggable blob storage backend (local FS, S3, …).
backend: Arc<dyn BlobStorageBackend>,
@@ -2078,10 +2214,17 @@ impl DedupService {
/// (for local backends) re-hashes to confirm content integrity.
pub async fn verify_integrity(&self) -> Result<Vec<String>, DomainError> {
const VERIFY_CONCURRENCY: usize = 16;
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;
let mut issues = Vec::new();
// ── Phase 1: Verify CDC manifests ────────────────────────
let manifests: Vec<(String, Vec<String>, Vec<i64>, i64)> = sqlx::query_as(
let manifests: Vec<IntegrityManifest> = sqlx::query_as(
"SELECT file_hash, chunk_hashes, chunk_sizes, total_size
FROM storage.chunk_manifests",
)
@@ -2089,42 +2232,137 @@ impl DedupService {
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("List manifests: {}", e)))?;
for (file_hash, chunk_hashes, chunk_sizes, total_size) in &manifests {
let label = &file_hash[..file_hash.len().min(12)];
// 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;
}
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}"
));
}
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 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]
));
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]
));
}
}
}
Err(_) => {
issues.push(format!(
Err(_) => issues.push(format!(
"Manifest {label} chunk {chunk_label}: missing in backend"
));
)),
}
}
}
} 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 {
issues.push(format!(
"Manifest {label}: total_size {total_size} != sum of chunk_sizes {sum}"
));
}
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;
}
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;
}
}
// ── Phase 2: Verify blobs (chunks + legacy) ──────────────
@@ -2261,6 +2499,13 @@ impl DedupService {
// where the PG trigger only touches storage.blobs and the
// per-file cleanup_if_orphaned call is skipped).
loop {
// 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.
let batch: Vec<(String, Vec<String>, i64)> = sqlx::query_as(
"DELETE FROM storage.chunk_manifests
WHERE ctid = ANY(
@@ -2283,27 +2528,64 @@ impl DedupService {
break;
}
for (file_hash, chunk_hashes, size) in &batch {
// 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 {
self.manifest_cache.invalidate(file_hash).await;
// Decrement chunk ref_counts. GREATEST(.., 0) guards against the
// single-chunk file case where the PG file-delete trigger already
// decremented blobs.ref_count (because file_hash == chunk_hash);
// without the clamp this would underflow the CHECK constraint.
// Stamp orphaned_at so chunks freed here get the same GC grace
// window as any other newly-orphaned blob.
}
if batch.len() == 1 {
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
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)",
)
.bind(chunk_hashes)
.bind(&batch[0].1)
.execute(self.maintenance_pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("GC decrement chunks: {e}"))
})?;
.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();
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 {
// 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
@@ -3167,6 +3449,72 @@ mod tests {
};
assert_eq!(outcome.distinct_hashes(), vec!["a", "b", "c"]);
}
#[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"
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -3549,6 +3897,14 @@ mod delta_upload_integration_tests {
use tempfile::TempDir;
use uuid::Uuid;
// GC sweeps the shared integration database globally, while every test
// intentionally owns a different TempDir-backed blob store. Running two
// sweep tests concurrently can therefore delete test A's row through test
// B's backend, leaving A's physical blob behind. Production has one shared
// backend for the swept database; serialize only these global-sweep tests
// so the integration topology models that invariant.
static GC_TEST_SERIALIZER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn test_pool() -> Arc<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(4)
@@ -3847,6 +4203,7 @@ mod delta_upload_integration_tests {
// ── Garbage collection: grace window + reference cross-checks ─
#[tokio::test]
async fn garbage_collect_honours_grace_window_and_references() {
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;
@@ -3941,9 +4298,113 @@ mod delta_upload_integration_tests {
cleanup(&pool, &file_hash, file_id, &[]).await;
}
// ── 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![1i64, 1, 1])
.bind(&orphan_b)
.bind(vec![shared.clone(), unique_b.clone()])
.bind(vec![1i64, 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;
}
// ── Manifest dereference defers chunk reclamation to GC ──────
#[tokio::test]
async fn manifest_dereference_defers_chunk_reclamation_to_gc() {
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;
@@ -87,9 +87,17 @@ async fn fsync_paths_parallel(paths: Vec<PathBuf>, strict: bool) -> Result<(), D
return Ok(());
}
let group_size = paths.len().div_ceil(SYNC_SWEEP_CONCURRENCY);
let mut tasks = Vec::with_capacity(SYNC_SWEEP_CONCURRENCY);
for group in paths.chunks(group_size) {
let group = group.to_vec();
let task_count = paths.len().min(SYNC_SWEEP_CONCURRENCY);
let mut source = paths.into_iter();
let mut tasks = Vec::with_capacity(task_count);
loop {
// `paths` is owned by this function. Move each PathBuf into its task
// group instead of cloning every allocation merely to satisfy the
// blocking task's `'static` lifetime.
let group: Vec<PathBuf> = source.by_ref().take(group_size).collect();
if group.is_empty() {
break;
}
tasks.push(tokio::task::spawn_blocking(
move || -> Result<(), (PathBuf, std::io::Error)> {
for path in &group {
@@ -122,6 +130,25 @@ async fn fsync_paths_parallel(paths: Vec<PathBuf>, strict: bool) -> Result<(), D
Ok(())
}
#[inline]
fn hex_prefix_symbol(byte: u8) -> Option<usize> {
match byte {
b'0'..=b'9' => Some((byte - b'0') as usize),
b'a'..=b'f' => Some((byte - b'a' + 10) as usize),
// Preserve the exact directory spelling. On a case-sensitive
// filesystem `af/` and `AF/` are different durability domains; folding
// them into one bitmap slot could omit one parent-directory fsync.
b'A'..=b'F' => Some((byte - b'A' + 16) as usize),
_ => None,
}
}
#[inline]
fn hash_prefix_slot(hash: &str) -> Option<usize> {
let bytes = hash.as_bytes();
Some(hex_prefix_symbol(*bytes.first()?)? * 22 + hex_prefix_symbol(*bytes.get(1)?)?)
}
/// Create `blob_path` and write `data` into it.
///
/// Returns the open file handle so the caller decides the durability tier
@@ -398,22 +425,45 @@ impl BlobStorageBackend for LocalBlobBackend {
&self,
hashes: &[String],
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let paths: Vec<PathBuf> = hashes.iter().map(|h| self.blob_path(h)).collect();
Box::pin(async move {
if paths.is_empty() {
return Ok(());
if hashes.is_empty() {
return Box::pin(async { Ok(()) });
}
let mut paths = Vec::with_capacity(hashes.len());
let mut dirs = Vec::with_capacity(hashes.len().min(HEX_PREFIXES.len()));
if let [hash] = hashes {
// Common tiny upload: reuse the already-built path's parent. This
// preserves the old one-item cost and avoids zeroing a bitmap whose
// O(1) advantage only starts once there is something to deduplicate.
let path = self.blob_path(hash);
if let Some(parent) = path.parent() {
dirs.push(parent.to_owned());
}
paths.push(path);
} else {
// 10 digits + 6 lowercase + 6 uppercase symbols per position. The
// 484-byte bitmap is still stack-only/O(1), while preserving exact
// parent paths on case-sensitive filesystems.
let mut seen_prefix = [false; 22 * 22];
for hash in hashes {
paths.push(self.blob_path(hash));
if let Some(slot) = hash_prefix_slot(hash) {
if !seen_prefix[slot] {
seen_prefix[slot] = true;
dirs.push(self.blob_root.join(&hash[..2]));
}
} else {
// `blob_path` already requires an ASCII two-byte prefix, and
// content hashes are canonical hex. Retain the old behaviour
// for a non-hex caller without panicking here: syncing a
// duplicate invalid parent is safer than silently omitting it.
dirs.push(self.blob_root.join(&hash[..2]));
}
}
}
Box::pin(async move {
// Each distinct prefix directory is fsync'd exactly once —
// chunks of one upload land in at most 256 prefix dirs, so
// this replaces one dir fsync *per chunk* with ≤256 total.
let mut dirs: Vec<PathBuf> = paths
.iter()
.filter_map(|p| p.parent().map(Path::to_path_buf))
.collect();
dirs.sort_unstable();
dirs.dedup();
// Files first (hard requirement), then dirents (best-effort,
// same tier as fsync_parent_dir).
fsync_paths_parallel(paths, true).await?;
@@ -647,4 +697,21 @@ mod tests {
backend.sync_blobs(&[]).await.unwrap();
}
#[test]
fn prefix_slots_cover_lowercase_hex_space_and_preserve_case() {
let mut seen = [false; 22 * 22];
for prefix in HEX_PREFIXES {
let hash = format!("{prefix}{}", "0".repeat(62));
let slot = hash_prefix_slot(&hash).unwrap();
assert!(!seen[slot]);
seen[slot] = true;
}
assert_eq!(seen.into_iter().filter(|value| *value).count(), 256);
assert_ne!(
hash_prefix_slot(&fake_hash("af")),
hash_prefix_slot(&fake_hash("aF"))
);
assert_eq!(hash_prefix_slot("gg"), None);
}
}