- Replace whole-file SHA-256 dedup with FastCDC 2020 content-defined chunking (min 64KB, avg 256KB, max 1MB) + BLAKE3 hashing - Add chunk_manifests table (file_hash → chunk_hashes[] + chunk_sizes[]) - Add put_blob_from_bytes to BlobStorageBackend trait (all 7 backends) - 3-phase store_chunks pipeline: Phase 0: batch-check existing chunks (single PG query) Phase 1: selective disk read (skip existing chunks entirely) Phase 2: parallel upload with buffer_unordered(8) - CDC-aware read_blob_stream and read_blob_range_stream with legacy fallback - Transactional manifest + chunk ref-count cascade on remove_reference - 12 CDC tests (determinism, reassembly, contiguity, sub-file dedup, etc.) - Update deduplication.md to reflect new architecture
20 KiB
06 - Deduplication
OxiCloud uses content-defined chunking (CDC) via FastCDC for sub-file deduplication. Files are split into variable-size chunks (64 KB – 1 MB, average 256 KB) using the FastCDC 2020 algorithm. Each chunk is individually BLAKE3-hashed and stored in a pluggable blob backend (local FS, S3, Azure). A PostgreSQL manifest maps the whole-file BLAKE3 hash to the ordered list of chunk hashes that compose it. Identical chunks across any files are stored once and reference-counted.
Deduplication is always enabled and non-fatal — if dedup fails, file operations proceed normally with a warning log.
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.
Architecture
┌─────────────────┐ ┌─────────────────────┐ ┌───────────────┐
│ storage.files │────▶│ chunk_manifests │────▶│ storage.blobs │──▶ Blob Store
│ (references) │ │ (file→[chunk_hashes])│ │ (chunks) │ (Local/S3/Azure)
└─────────────────┘ └─────────────────────┘ └───────────────┘
Database Tables
| Table | Schema | Purpose |
|---|---|---|
storage.chunk_manifests |
storage |
Maps file_hash → ordered chunk_hashes[] + chunk_sizes[] + ref_count |
storage.blobs |
storage |
Per-chunk metadata: hash (PK), size, ref_count, content_type |
Defined in migrations/20260414000000_chunk_manifests.sql. Blobs table is part of the initial schema.
Layer Placement
| Layer | Component | File |
|---|---|---|
| Application Port | DedupPort trait + DTOs | src/application/ports/dedup_ports.rs |
| Application Port | BlobStorageBackend trait | src/application/ports/blob_storage_ports.rs |
| Infrastructure | DedupService implementation | src/infrastructure/services/dedup_service.rs |
| Infrastructure | Blob backends (Local, S3, Azure, Retry, Encrypted, Cached, Migration) | src/infrastructure/services/*_blob_backend.rs |
| Interfaces | DedupHandler REST endpoints | src/interfaces/api/handlers/dedup_handler.rs |
| Integration | FileBlobWriteRepository (dedup on upload) | src/infrastructure/repositories/pg/file_blob_write_repository.rs |
| Integration | FileBlobReadRepository (dedup reads) | src/infrastructure/repositories/pg/file_blob_read_repository.rs |
Constants
| Constant | Value | Description |
|---|---|---|
CDC_MIN_CHUNK |
64 KB (65_536) |
Minimum CDC chunk size |
CDC_AVG_CHUNK |
256 KB (262_144) |
Average / target CDC chunk size |
CDC_MAX_CHUNK |
1 MB (1_048_576) |
Maximum CDC chunk size |
CHUNK_UPLOAD_CONCURRENCY |
8 | Maximum parallel chunk uploads to blob backend |
Hardcoded in dedup_service.rs.
Write Path: store_from_file
The core write operation follows a write-first strategy that never holds a PG connection during disk I/O:
store_from_file(source_path, content_type, pre_computed_hash)
│
├─ Fast path: pre_computed_hash provided?
│ └─ try_dedup_hit() → check manifest + legacy blob
│ └─ Hit? → bump ref_count, delete source, return ExistingBlob
│
├─ CDC analysis (single mmap pass, spawn_blocking):
│ ├─ Memory-map the file (memmap2)
│ ├─ FastCDC 2020 boundary detection → ChunkMeta[]
│ └─ BLAKE3 whole-file hash (concurrent with chunking)
│
├─ Second dedup check with computed hash (if no pre_computed_hash)
│
├─ store_chunks() — 3-phase pipeline:
│ │
│ ├─ Phase 0: Batch-check existing chunks (single PG query)
│ │ SELECT hash FROM storage.blobs WHERE hash = ANY($1)
│ │ → HashSet<String> of already-stored chunk hashes
│ │
│ ├─ Phase 1: Selective disk read (sequential, one pass)
│ │ For each chunk:
│ │ existing? → skip read (None)
│ │ new? → seek + read_exact → Some(Bytes)
│ │
│ └─ Phase 2: Parallel operations (buffer_unordered × 8)
│ new chunk: put_blob_from_bytes + INSERT ON CONFLICT
│ existing chunk: UPDATE ref_count + 1 (no disk I/O)
│
├─ INSERT manifest into storage.chunk_manifests
│ (file_hash, chunk_hashes[], chunk_sizes[], total_size, chunk_count)
│
└─ Delete source file, return NewBlob { hash, size }
Dedup Skip Optimization
The biggest I/O saving for versioned files. Before reading any chunk from disk or uploading it to the blob backend, store_chunks batch-queries PG to discover which chunk hashes already exist:
SELECT hash FROM storage.blobs WHERE hash = ANY($1)
This single round-trip returns all known chunks. For each existing chunk, the service skips:
seek()+read_exact()from the source file (no disk I/O)put_blob_from_bytes()to the backend (no network I/O for S3/Azure)
Only a lightweight UPDATE ref_count + 1 is executed in PG (~0.1 ms per chunk).
Impact: for a 100 MB versioned file where 95% of chunks are unchanged, only ~5 MB is read from disk and uploaded. The remaining 95% costs only PG ref-count bumps.
Parallel Chunk Storage
Phase 2 of store_chunks uses futures::stream::buffer_unordered(8) to execute up to 8 concurrent chunk operations. This is a major win for S3/Azure backends where each PUT has 50-200 ms of network latency.
Chunk order in the returned (chunk_hashes, chunk_sizes) is preserved by deriving both from the original ChunkMeta slice (CDC order), not from the unordered parallel results.
Full-File Dedup Hit (Fast Path)
When a file with the exact same BLAKE3 hash already has a manifest, try_dedup_hit returns immediately:
- Bumps
chunk_manifests.ref_count - Deletes the source file
- Returns
ExistingBlob— zero chunk I/O
Also checks legacy whole-file blobs in storage.blobs for backward compatibility.
Read Path
Streaming Read (read_blob_stream)
CDC-aware with legacy fallback:
- Query
chunk_manifestsforchunk_hashes[] - If found: stream chunks in order via
backend.get_blob_stream(chunk_hash), concatenated into a single byte stream withbuffered(1) + try_flatten - If not found: fall back to
backend.get_blob_stream(hash)for legacy blobs
Range Read (read_blob_range_stream)
For HTTP Range requests (and WOPI/WebDAV partial reads):
- Query manifest for
chunk_hashes[],chunk_sizes[],total_size - Calculate which chunks overlap
[start, end)using cumulative offsets - For each overlapping chunk, compute the sub-range within that chunk
- Stream only the relevant chunk portions via
backend.get_blob_range_stream()
Blob Size (blob_size)
Returns total_size from the manifest (O(1) PG lookup). Falls back to backend.blob_size() for legacy blobs. Used by HEAD requests for Content-Length.
Reference Counting
Adding References (add_reference)
Manifest-aware with legacy fallback:
- Try
UPDATE chunk_manifests SET ref_count = ref_count + 1 WHERE file_hash = $1 - If no rows affected, try
UPDATE storage.blobs SET ref_count + 1 WHERE hash = $1 - If neither exists, return NotFound error
Removing References (remove_reference)
CDC manifest path (transactional):
- Check
chunk_manifestsfor the file hash - If
ref_count > 1: decrement manifest ref_count → commit - If
ref_count == 1(last reference):SELECT ... FOR UPDATEto lock the manifest rowDELETE FROM chunk_manifestsUPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY(chunk_hashes)DELETE FROM storage.blobs WHERE hash = ANY(chunk_hashes) AND ref_count <= 0 RETURNING hash- Commit TX
- Delete orphaned chunk blob files from backend (after commit)
Legacy blob path (transactional):
SELECT ref_count, size FROM storage.blobs WHERE hash = $1 FOR UPDATE- If
ref_count == 1:DELETE FROM storage.blobs+ delete blob file - If
ref_count > 1:UPDATE SET ref_count = ref_count - 1
Port: DedupPort Trait
Defined in src/application/ports/dedup_ports.rs:
pub trait DedupPort: Send + Sync + 'static {
/// Store content with CDC deduplication (from file).
async fn store_from_file(
&self,
source_path: &Path,
content_type: Option<String>,
pre_computed_hash: Option<String>,
) -> Result<DedupResultDto, DomainError>;
/// Check if a blob exists by hash (manifest or legacy).
async fn blob_exists(&self, hash: &str) -> bool;
/// Get metadata for a blob.
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto>;
/// Stream blob content — CDC-aware with legacy fallback.
async fn read_blob_stream(&self, hash: &str)
-> Result<Pin<Box<dyn Stream<Item = Result<Bytes, io::Error>> + Send>>, DomainError>;
/// Stream a byte range — CDC-aware with legacy fallback.
async fn read_blob_range_stream(&self, hash: &str, start: u64, end: Option<u64>)
-> Result<Pin<Box<dyn Stream<Item = Result<Bytes, io::Error>> + Send>>, DomainError>;
/// Get blob size without reading content.
async fn blob_size(&self, hash: &str) -> Result<u64, DomainError>;
/// Increment reference count (manifest-aware).
async fn add_reference(&self, hash: &str) -> Result<(), DomainError>;
/// Decrement reference count. Returns true if blob was deleted.
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
/// Calculate BLAKE3 hash of a file (mmap + rayon).
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
/// Get local filesystem path for a blob hash.
fn blob_path(&self, hash: &str) -> PathBuf;
/// Get deduplication statistics (computed from PG).
async fn get_stats(&self) -> DedupStatsDto;
/// Flush index to persistent storage (no-op for PG backend).
async fn flush(&self) -> Result<(), DomainError>;
/// Verify integrity of all stored blobs and manifests.
async fn verify_integrity(&self) -> Result<Vec<String>, DomainError>;
}
Port DTOs
/// Result of a dedup store operation.
pub enum DedupResultDto {
NewBlob { hash: String, size: u64 },
ExistingBlob { hash: String, size: u64, saved_bytes: u64 },
}
// Methods: hash(), size(), was_deduplicated()
/// Metadata for a stored blob.
pub struct BlobMetadataDto {
pub hash: String, // BLAKE3 hex string
pub size: u64,
pub ref_count: u32,
pub content_type: Option<String>,
}
/// Aggregate dedup statistics (computed from PG).
pub struct DedupStatsDto {
pub total_blobs: u64,
pub total_bytes_stored: u64,
pub total_bytes_referenced: u64,
pub bytes_saved: u64,
pub dedup_hits: u64,
pub dedup_ratio: f64,
}
Infrastructure: DedupService
Implemented in src/infrastructure/services/dedup_service.rs.
Struct
pub struct DedupService {
backend: Arc<dyn BlobStorageBackend>, // Pluggable blob storage (Local/S3/Azure/...)
pool: Arc<PgPool>, // Primary pool (request-path operations)
maintenance_pool: Arc<PgPool>, // Isolated pool (verify_integrity, GC)
}
Key Methods
| Method | Description |
|---|---|
new(backend, pool, maintenance_pool) |
Construct — wires pluggable backend + dual PG pools |
initialize() |
Initialize backend + log blob/manifest counts from PG |
cdc_hash_and_chunk_file(path) |
Single mmap pass: BLAKE3 whole-file hash + FastCDC chunk boundaries + per-chunk BLAKE3 |
cdc_chunk_file(path) |
CDC without whole-file hash (when hash is pre-computed) |
hash_file(path) |
BLAKE3 hash via mmap + rayon parallelism |
store_from_file(path, ct, hash) |
CDC → store_chunks → manifest INSERT (main write path) |
try_dedup_hit(hash, path) |
Check manifest/legacy for full-file dedup hit |
store_chunks(path, chunks) |
3-phase: batch-check → selective read → parallel upload |
blob_exists(hash) |
Check manifest + legacy blob existence |
user_owns_blob_reference(hash, user_id) |
Authorization: check file ownership |
get_blob_metadata(hash) |
Manifest-aware metadata with legacy fallback |
add_reference(hash) |
Manifest-aware ref_count increment |
remove_reference(hash) |
Manifest-aware ref_count decrement + cascade cleanup |
read_blob_stream(hash) |
CDC chunk-streaming with legacy fallback |
read_blob_range_stream(hash, start, end) |
CDC range-streaming with legacy fallback |
blob_size(hash) |
O(1) from manifest, fallback to backend |
get_stats() |
Compute stats from PG (blobs + manifests) |
verify_integrity() |
Verify manifests (counts, sizes) + blobs (existence, size, re-hash) |
garbage_collect() |
Batch-delete orphaned manifests/blobs (uses maintenance pool) |
Key Behaviors
- CDC analysis in
spawn_blocking: mmap + FastCDC runs off the async runtime to avoid blocking the event loop - Dual PG pools: request-path operations use the primary pool;
verify_integrityandgarbage_collectuse the maintenance pool to prevent starvation - Pluggable blob backend: all chunk I/O goes through
Arc<dyn BlobStorageBackend>— works with local FS, S3, Azure, or any composed backend (retry, encryption, caching) - Atomic chunk storage:
put_blob_from_bytesis idempotent;INSERT ON CONFLICThandles concurrent uploads of the same chunk - Delete-after-commit: blob files are deleted from the backend only after the PG transaction commits, preventing orphaned PG rows
- Flush is no-op: PG handles durability via WAL/commit — no explicit index persistence needed
REST API Endpoints
All routes under /api/dedup, authentication required.
| Method | Path | Handler | Description |
|---|---|---|---|
GET |
/api/dedup/check/{hash} |
DedupHandler::check_hash |
Check if a blob exists by BLAKE3 hash |
POST |
/api/dedup/upload |
DedupHandler::upload_with_dedup |
Multipart upload with automatic dedup |
GET |
/api/dedup/stats |
DedupHandler::get_stats |
Get deduplication statistics |
GET |
/api/dedup/blob/{hash} |
DedupHandler::get_blob |
Retrieve raw blob content by hash |
DELETE |
/api/dedup/blob/{hash} |
DedupHandler::remove_reference |
Decrement ref-count (deletes blob if 0) |
POST |
/api/dedup/recalculate |
DedupHandler::recalculate_stats |
Run integrity verification + refresh stats |
API Response Types
Hash Check (GET /api/dedup/check/{hash}):
{
"exists": true,
"hash": "a1b2c3d4...",
"existing_size": 1048576,
"ref_count": 3
}
Dedup Upload (POST /api/dedup/upload):
{
"is_new": false,
"hash": "a1b2c3d4...",
"size": 1048576,
"bytes_saved": 1048576,
"ref_count": 2
}
Stats (GET /api/dedup/stats):
{
"unique_blobs": 150,
"total_references": 300,
"bytes_saved": 524288000,
"total_logical_bytes": 1073741824,
"total_physical_bytes": 549453824,
"dedup_ratio": 2.0,
"savings_percentage": 48.8
}
DI Wiring
In src/common/di.rs:
// Blob backend is assembled from layered backends:
// LocalBlobBackend / S3BlobBackend / AzureBlobBackend
// → RetryBlobBackend → EncryptedBlobBackend → CachedBlobBackend
let blob_backend: Arc<dyn BlobStorageBackend> = /* ... */;
// DedupService receives the composed blob backend + dual PG pools
let dedup_service = Arc::new(DedupService::new(
blob_backend,
db_pool.clone(),
maintenance_pool.clone(),
));
dedup_service.initialize().await?;
// Stored in CoreServices as:
pub struct CoreServices {
pub dedup_service: Arc<dyn DedupPort>,
// ...
}
// Injected into blob repositories:
FileBlobReadRepository::new(pool, core.dedup_service.clone(), folder_repo)
FileBlobWriteRepository::new(pool, core.dedup_service.clone(), folder_repo)
Maintenance
Garbage Collection (garbage_collect)
Two-phase batch deletion using the maintenance pool:
- Phase 1 — Orphaned manifests:
DELETE FROM chunk_manifests WHERE ref_count <= 0(batches of 500). For each deleted manifest,UPDATE storage.blobs SET ref_count = ref_count - 1for its chunks. - Phase 2 — Orphaned blobs:
DELETE FROM storage.blobs WHERE ref_count <= 0(batches of 500). Deletes blob files from backend + thumbnail cleanup (best-effort).
Uses tokio::task::yield_now() between batches to avoid starving other tasks.
Integrity Verification (verify_integrity)
Phase 1 — Verify CDC manifests:
chunk_hashes.len() == chunk_sizes.len()SUM(chunk_sizes) == total_size- Every referenced chunk exists in the blob backend with correct size
Phase 2 — Verify blobs (chunks + legacy):
- Blob file exists in backend
- Actual size matches PG record
- (Local backends only) Re-hash file content to verify BLAKE3 integrity
- Processes 16 blobs concurrently via
buffer_unordered
Tests
Located at the bottom of src/infrastructure/services/dedup_service.rs (12 tests):
| Test | Description |
|---|---|
test_cdc_deterministic_same_content |
Same content → same file hash + same chunk hashes/offsets/lengths |
test_cdc_empty_file |
Empty file → zero chunks, correct BLAKE3 empty hash |
test_cdc_small_file_single_chunk |
File below min chunk → single chunk covering entire file |
test_cdc_chunk_sizes_within_bounds |
All non-last chunks are within [64 KB, 1 MB] |
test_cdc_file_hash_matches_hash_file |
CDC whole-file hash matches standalone hash_file() |
test_cdc_chunk_hashes_are_correct |
Each chunk hash == BLAKE3 of that chunk's data |
test_cdc_reassembly_matches_original |
Concatenating chunks reproduces original file |
test_cdc_chunks_are_contiguous |
Chunks cover entire file with no gaps or overlaps |
test_cdc_similar_files_share_chunks |
Editing last 64 KB of 2 MB file → most chunks shared |
test_cdc_chunk_file_matches_full |
cdc_chunk_file produces same chunks as cdc_hash_and_chunk_file |
test_cdc_large_file_chunk_count |
8 MB file produces 8-128 chunks (avg ~256 KB) |
test_cdc_insert_at_beginning_preserves_later_chunks |
128 KB prefix insert → CDC resynchronizes, later chunks shared |
Performance Characteristics
| Scenario | Behavior |
|---|---|
| First upload of new file | Single mmap pass (CDC + hash) → parallel chunk upload → manifest INSERT |
| Re-upload of identical file | try_dedup_hit → manifest ref_count bump → zero chunk I/O |
| Upload of edited file (5% changed) | CDC → batch-check finds 95% existing → reads only 5% → uploads 5% → ref-bumps 95% |
| Range read (1 MB from 1 GB file) | Manifest lookup → identify overlapping chunks → stream only those portions |
| Delete last reference | TX: delete manifest → batch-decrement chunks → delete zero-ref chunks → commit → delete blob files |
| Garbage collection | Maintenance pool, batches of 500, yields between batches |
Client Usage Example
# 1. Check if file already exists by hash
HASH=$(b3sum myfile.txt | cut -d' ' -f1)
curl -H "Authorization: Bearer $TOKEN" \
"https://oxicloud.example.com/api/dedup/check/$HASH"
# 2. Upload with dedup (if not exists)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@myfile.txt" \
"https://oxicloud.example.com/api/dedup/upload"
# 3. Get dedup statistics
curl -H "Authorization: Bearer $TOKEN" \
"https://oxicloud.example.com/api/dedup/stats"
# 4. Retrieve blob content
curl -H "Authorization: Bearer $TOKEN" \
"https://oxicloud.example.com/api/dedup/blob/$HASH" -o output.bin
# 5. Recalculate stats with integrity check
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://oxicloud.example.com/api/dedup/recalculate"