feat: pluggable storage backends (S3, Azure, local) with admin UI

Implement 4-phase external storage backends architecture:

Phase 1 - Foundation:
- BlobStorageBackend trait (application/ports/blob_storage_ports.rs)
- LocalBlobBackend: extracted all tokio::fs ops from DedupService
- S3BlobBackend: AWS SDK with custom endpoint support (MinIO, R2, B2)
- DedupService refactored to use Arc<dyn BlobStorageBackend>

Phase 2 - Admin Panel:
- StorageSettingsService with DB persistence + env override
- Storage tab in admin panel (backend selector, S3 form, provider presets)
- GET/PUT/POST endpoints for storage settings + connection test
- i18n keys (en/es) and BEM CSS

Phase 3 - Migration:
- MigrationBlobBackend decorator (dual-read: target-first + source fallback)
- Background migration job with parallel transfers + progress tracking
- Migration UI (progress bar, ETA, pause/resume/verify/complete)
- 6 admin API endpoints for migration lifecycle

Phase 4 - Enterprise Extras:
- CachedBlobBackend: LRU disk cache for remote backends
- EncryptedBlobBackend: AES-256-GCM at-rest encryption
- AzureBlobBackend: Azure Blob Storage support
- RetryBlobBackend: exponential backoff for transient errors
- Decorator composition in DI: retry → encryption → cache

All 223 tests passing, clippy clean, fmt verified.
This commit is contained in:
Diocrafts
2026-04-14 21:33:38 +02:00
parent 6fc632af7e
commit cd3733b459
26 changed files with 6870 additions and 308 deletions
@@ -0,0 +1,302 @@
//! Azure Blob Storage Backend — stores blobs in an Azure Storage container.
//!
//! Authenticates via Account Name + Account Key (or SAS token).
//! Blob key scheme mirrors local/S3: `{2-char-prefix}/{hash}.blob`.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use azure_storage::StorageCredentials;
use azure_storage_blobs::prelude::*;
use bytes::Bytes;
use futures::StreamExt;
use tokio::fs;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::common::config::AzureStorageConfig;
use crate::domain::errors::{DomainError, ErrorKind};
/// Azure Blob Storage backend.
pub struct AzureBlobBackend {
container_client: ContainerClient,
container_name: String,
}
impl AzureBlobBackend {
/// Build a new Azure backend from configuration.
pub fn new(config: &AzureStorageConfig) -> Self {
let credentials = if let Some(ref sas) = config.sas_token {
StorageCredentials::sas_token(sas).expect("Invalid SAS token")
} else {
StorageCredentials::access_key(&config.account_name, config.account_key.clone())
};
let container_client = ClientBuilder::new(&config.account_name, credentials)
.container_client(&config.container);
Self {
container_client,
container_name: config.container.clone(),
}
}
/// Compute the blob name for a given hash.
fn blob_name(hash: &str) -> String {
let prefix = &hash[0..2];
format!("{prefix}/{hash}.blob")
}
/// Get a `BlobClient` for a given hash.
fn blob_client(&self, hash: &str) -> BlobClient {
self.container_client.blob_client(Self::blob_name(hash))
}
}
impl BlobStorageBackend for AzureBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
Box::pin(async move {
// Verify container exists by getting its properties
self.container_client.get_properties().await.map_err(|e| {
DomainError::internal_error(
"Azure",
format!("Cannot access container '{}': {}", self.container_name, e),
)
})?;
tracing::info!(
"Azure blob backend initialized: container={}",
self.container_name
);
Ok(())
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
let source_path = source_path.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
// Check if blob already exists (idempotent)
if client.get_properties().await.is_ok() {
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error(
"Azure",
format!("Failed to stat source file: {e}"),
)
})?
.len();
let _ = fs::remove_file(&source_path).await;
return Ok(file_size);
}
// Read file and upload as block blob
let data = fs::read(&source_path).await.map_err(|e| {
DomainError::internal_error("Azure", format!("Failed to read source: {e}"))
})?;
let file_size = data.len() as u64;
client.put_block_blob(data).await.map_err(|e| {
DomainError::internal_error("Azure", format!("Failed to upload blob {hash}: {e}"))
})?;
let _ = fs::remove_file(&source_path).await;
Ok(file_size)
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
let mut result_data: Vec<u8> = Vec::new();
let mut stream = client.get().into_stream();
while let Some(response) = stream.next().await {
let response = response.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Azure",
format!("Failed to get blob {hash}: {e}"),
)
})?;
let mut body = response.data;
while let Some(chunk) = body.next().await {
let chunk = chunk.map_err(|e| {
DomainError::internal_error("Azure", format!("Stream read error: {e}"))
})?;
result_data.extend_from_slice(&chunk);
}
}
let stream: BlobStream = Box::pin(futures::stream::once(async move {
Ok(Bytes::from(result_data))
}));
Ok(stream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
let range = match end {
Some(e) => azure_core::request_options::Range::new(start, e),
None => azure_core::request_options::Range::new(start, u64::MAX),
};
let mut result_data: Vec<u8> = Vec::new();
let mut stream = client.get().range(range).into_stream();
while let Some(response) = stream.next().await {
let response = response.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Azure",
format!("Failed to get blob range {hash}: {e}"),
)
})?;
let mut body = response.data;
while let Some(chunk) = body.next().await {
let chunk = chunk.map_err(|e| {
DomainError::internal_error(
"Azure",
format!("Stream range read error: {e}"),
)
})?;
result_data.extend_from_slice(&chunk);
}
}
let stream: BlobStream = Box::pin(futures::stream::once(async move {
Ok(Bytes::from(result_data))
}));
Ok(stream)
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
// Azure delete is not fully idempotent — 404 is expected for missing blobs
match client.delete().await {
Ok(_) => Ok(()),
Err(e) => {
// If 404, treat as success (idempotent)
let status = e.as_http_error().map(|h| h.status());
if status == Some(azure_core::StatusCode::NotFound) {
Ok(())
} else {
Err(DomainError::internal_error(
"Azure",
format!("Failed to delete blob {hash}: {e}"),
))
}
}
}
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
match client.get_properties().await {
Ok(_) => Ok(true),
Err(e) => {
let status = e.as_http_error().map(|h| h.status());
if status == Some(azure_core::StatusCode::NotFound) {
Ok(false)
} else {
Err(DomainError::internal_error(
"Azure",
format!("Failed to check blob {hash}: {e}"),
))
}
}
}
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let client = self.blob_client(&hash);
let props = client.get_properties().await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Azure",
format!("Failed to stat blob {hash}: {e}"),
)
})?;
Ok(props.blob.properties.content_length)
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
Box::pin(async move {
match self.container_client.get_properties().await {
Ok(_) => Ok(StorageHealthStatus {
connected: true,
backend_type: "azure".to_string(),
message: format!("Azure container '{}' is accessible", self.container_name),
available_bytes: None,
}),
Err(e) => Ok(StorageHealthStatus {
connected: false,
backend_type: "azure".to_string(),
message: format!(
"Azure container '{}' is not accessible: {}",
self.container_name, e
),
available_bytes: None,
}),
}
})
}
fn backend_type(&self) -> &'static str {
"azure"
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None
}
}
@@ -0,0 +1,472 @@
//! `CachedBlobBackend` — LRU local-disk cache decorator for remote blob backends.
//!
//! Wraps any `BlobStorageBackend` (typically S3 or Azure) and transparently
//! caches hot blobs on a local SSD. Reads check the cache first; cache misses
//! are fetched from the inner backend and written to the cache. Writes go to
//! the inner backend AND the local cache simultaneously.
//!
//! Eviction is LRU based on a configurable maximum disk budget.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use lru::LruCache;
use std::num::NonZeroUsize;
use tokio::fs;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio_util::io::ReaderStream;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::domain::errors::DomainError;
/// Chunk size for streaming cached file reads (256 KB).
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
// ── Configuration ──────────────────────────────────────────────────
/// Configuration for the LRU disk cache.
#[derive(Debug, Clone)]
pub struct BlobCacheConfig {
/// Directory where cached blobs are stored.
pub cache_dir: PathBuf,
/// Maximum total cache size in bytes.
pub max_cache_bytes: u64,
}
// ── Cache entry ────────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct CacheEntry {
size: u64,
}
// ── CachedBlobBackend ──────────────────────────────────────────────
/// A `BlobStorageBackend` decorator that adds an LRU disk cache in front of
/// a remote backend.
pub struct CachedBlobBackend {
inner: Arc<dyn BlobStorageBackend>,
cache_dir: PathBuf,
max_cache_bytes: u64,
index: Arc<Mutex<LruCache<String, CacheEntry>>>,
current_size: Arc<AtomicU64>,
}
impl CachedBlobBackend {
/// Create a new cached backend wrapping `inner`.
pub fn new(inner: Arc<dyn BlobStorageBackend>, config: &BlobCacheConfig) -> Self {
Self {
inner,
cache_dir: config.cache_dir.clone(),
max_cache_bytes: config.max_cache_bytes,
// Capacity is essentially unbounded — eviction is by byte budget, not count.
index: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(1_000_000).unwrap(),
))),
current_size: Arc::new(AtomicU64::new(0)),
}
}
/// Path where a blob is cached locally.
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
}
impl BlobStorageBackend for CachedBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let cache_dir = self.cache_dir.clone();
let index = self.index.clone();
let current_size = self.current_size.clone();
Box::pin(async move {
inner.initialize().await?;
// Create cache dir structure (256 prefix dirs)
fs::create_dir_all(&cache_dir).await.map_err(|e| {
DomainError::internal_error("BlobCache", format!("mkdir cache_dir: {e}"))
})?;
// Scan existing cache to rebuild index
let mut total_bytes = 0u64;
let mut idx = index.lock().await;
if let Ok(mut read_dir) = fs::read_dir(&cache_dir).await {
while let Ok(Some(prefix_entry)) = read_dir.next_entry().await {
if !prefix_entry.path().is_dir() {
continue;
}
if let Ok(mut sub_dir) = fs::read_dir(prefix_entry.path()).await {
while let Ok(Some(entry)) = sub_dir.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("blob")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
let size = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0);
idx.put(stem.to_string(), CacheEntry { size });
total_bytes += size;
}
}
}
}
}
drop(idx);
current_size.store(total_bytes, Ordering::Relaxed);
tracing::info!(
"Blob cache initialized: {} bytes in cache at {}",
total_bytes,
cache_dir.display()
);
Ok(())
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let source = source_path.to_path_buf();
let self_ref = CachedRef {
cache_dir: self.cache_dir.clone(),
max_cache_bytes: self.max_cache_bytes,
index: self.index.clone(),
current_size: self.current_size.clone(),
};
Box::pin(async move {
// Write to inner backend
let bytes = inner.put_blob(&hash, &source).await?;
// Also cache locally (best-effort)
let _ = self_ref.insert_into_cache_static(&hash, &source).await;
Ok(bytes)
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_string();
let cached = self.cached_path(&hash);
let index = self.index.clone();
let inner = self.inner.clone();
let cache_dir = self.cache_dir.clone();
let max_cache_bytes = self.max_cache_bytes;
let current_size = self.current_size.clone();
Box::pin(async move {
// Check cache
{
let mut idx = index.lock().await;
if idx.get(&hash).is_some() {
if let Ok(file) = fs::File::open(&cached).await {
let stream: BlobStream =
Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE));
return Ok(stream);
}
// Cache entry stale — remove
if let Some(entry) = idx.pop(&hash) {
current_size.fetch_sub(entry.size, Ordering::Relaxed);
}
}
}
// Cache miss — fetch from inner, spool to cache
let self_ref = CachedRef {
cache_dir,
max_cache_bytes,
index: index.clone(),
current_size: current_size.clone(),
};
let dest = self_ref.fetch_and_cache_static(&hash, &*inner).await?;
let file = fs::File::open(&dest).await.map_err(|e| {
DomainError::internal_error("BlobCache", format!("re-open cached: {e}"))
})?;
let stream: BlobStream = Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE));
Ok(stream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_string();
let cached = self.cached_path(&hash);
let index = self.index.clone();
let inner = self.inner.clone();
let cache_dir = self.cache_dir.clone();
let max_cache_bytes = self.max_cache_bytes;
let current_size = self.current_size.clone();
Box::pin(async move {
// Try cache first
{
let mut idx = index.lock().await;
if idx.get(&hash).is_some() {
if let Ok(mut file) = fs::File::open(&cached).await {
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 limited = file.take(take_len);
let stream: BlobStream =
Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE));
return Ok(stream);
}
if let Some(entry) = idx.pop(&hash) {
current_size.fetch_sub(entry.size, Ordering::Relaxed);
}
}
}
// Cache miss — fetch full blob into cache, then serve range
let self_ref = CachedRef {
cache_dir,
max_cache_bytes,
index: index.clone(),
current_size: current_size.clone(),
};
let dest = self_ref.fetch_and_cache_static(&hash, &*inner).await?;
let mut file = fs::File::open(&dest)
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("re-open: {e}")))?;
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 limited = file.take(take_len);
let stream: BlobStream =
Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE));
Ok(stream)
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let cached = self.cached_path(&hash);
let index = self.index.clone();
let current_size = self.current_size.clone();
Box::pin(async move {
inner.delete_blob(&hash).await?;
// Remove from cache
let mut idx = index.lock().await;
if let Some(entry) = idx.pop(&hash) {
current_size.fetch_sub(entry.size, Ordering::Relaxed);
}
let _ = fs::remove_file(&cached).await;
Ok(())
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let index = self.index.clone();
Box::pin(async move {
// Check cache first (fast)
{
let mut idx = index.lock().await;
if idx.get(&hash).is_some() {
return Ok(true);
}
}
inner.blob_exists(&hash).await
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let index = self.index.clone();
let cached = self.cached_path(&hash);
Box::pin(async move {
// Check cache
{
let mut idx = index.lock().await;
if let Some(entry) = idx.get(&hash) {
return Ok(entry.size);
}
}
// Fallback to cached file on disk (in case index was lost)
if let Ok(meta) = fs::metadata(&cached).await {
return Ok(meta.len());
}
inner.blob_size(&hash).await
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
let inner = self.inner.clone();
let cache_dir = self.cache_dir.clone();
let current_size = self.current_size.clone();
let max_bytes = self.max_cache_bytes;
Box::pin(async move {
let mut status = inner.health_check().await?;
let used = current_size.load(Ordering::Relaxed);
status.message = format!(
"{} | Cache: {}/{} bytes used at {}",
status.message,
used,
max_bytes,
cache_dir.display()
);
status.backend_type = format!("cached({})", status.backend_type);
Ok(status)
})
}
fn backend_type(&self) -> &'static str {
"cached"
}
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
// If the blob is cached locally, return that path
let path = self.cached_path(hash);
if path.exists() { Some(path) } else { None }
}
}
// ── Helper struct for owned references in async closures ───────────
/// Cloneable set of cache internals — avoids borrow issues in boxed futures.
struct CachedRef {
cache_dir: PathBuf,
max_cache_bytes: u64,
index: Arc<Mutex<LruCache<String, CacheEntry>>>,
current_size: Arc<AtomicU64>,
}
impl CachedRef {
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
async fn insert_into_cache_static(
&self,
hash: &str,
source_path: &Path,
) -> Result<(), DomainError> {
let dest = self.cached_path(hash);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
DomainError::internal_error("BlobCache", format!("mkdir failed: {e}"))
})?;
}
let size = fs::metadata(source_path)
.await
.map(|m| m.len())
.unwrap_or(0);
fs::copy(source_path, &dest).await.map_err(|e| {
DomainError::internal_error("BlobCache", format!("cache copy failed: {e}"))
})?;
let mut idx = self.index.lock().await;
if let Some(old) = idx.put(hash.to_string(), CacheEntry { size }) {
self.current_size.fetch_sub(old.size, Ordering::Relaxed);
}
self.current_size.fetch_add(size, Ordering::Relaxed);
while self.current_size.load(Ordering::Relaxed) > self.max_cache_bytes {
if let Some((evicted_hash, evicted_entry)) = idx.pop_lru() {
self.current_size
.fetch_sub(evicted_entry.size, Ordering::Relaxed);
let evicted_path = self.cached_path(&evicted_hash);
let _ = fs::remove_file(&evicted_path).await;
} else {
break;
}
}
Ok(())
}
async fn fetch_and_cache_static(
&self,
hash: &str,
inner: &dyn BlobStorageBackend,
) -> Result<PathBuf, DomainError> {
let stream = inner.get_blob_stream(hash).await?;
let dest = self.cached_path(hash);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
DomainError::internal_error("BlobCache", format!("mkdir failed: {e}"))
})?;
}
let tmp = dest.with_extension("tmp");
let mut file = fs::File::create(&tmp)
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("create tmp: {e}")))?;
use futures::StreamExt;
let mut stream = stream;
let mut total = 0u64;
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(|e| {
DomainError::internal_error("BlobCache", format!("stream read: {e}"))
})?;
total += bytes.len() as u64;
file.write_all(&bytes)
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("write: {e}")))?;
}
file.flush()
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("flush: {e}")))?;
drop(file);
fs::rename(&tmp, &dest)
.await
.map_err(|e| DomainError::internal_error("BlobCache", format!("rename: {e}")))?;
let mut idx = self.index.lock().await;
if let Some(old) = idx.put(hash.to_string(), CacheEntry { size: total }) {
self.current_size.fetch_sub(old.size, Ordering::Relaxed);
}
self.current_size.fetch_add(total, Ordering::Relaxed);
while self.current_size.load(Ordering::Relaxed) > self.max_cache_bytes {
if let Some((evicted_hash, evicted_entry)) = idx.pop_lru() {
self.current_size
.fetch_sub(evicted_entry.size, Ordering::Relaxed);
let evicted_path = self.cached_path(&evicted_hash);
let _ = fs::remove_file(&evicted_path).await;
} else {
break;
}
}
Ok(dest)
}
}
+80 -211
View File
@@ -39,24 +39,22 @@ use sqlx::PgPool;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use tokio::fs::{self, File};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
use tokio::fs;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::application::ports::dedup_ports::{
BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Chunk size for streaming file reads (256 KB)
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
/// Content-Addressable Storage Service (PostgreSQL-backed)
///
/// Delegates all byte-level I/O to a [`BlobStorageBackend`] implementation
/// (local filesystem, S3, etc.) while keeping BLAKE3 hashing, ref-counting
/// and the PostgreSQL dedup index here.
pub struct DedupService {
/// Root directory for blob storage on the filesystem
blob_root: PathBuf,
/// Root directory for temporary files during upload
temp_root: PathBuf,
/// Pluggable blob storage backend (local FS, S3, …).
backend: Arc<dyn BlobStorageBackend>,
/// PostgreSQL connection pool (dedup index in `storage.blobs`) — primary,
/// used by request-path operations (store_from_file, etc.).
pool: Arc<PgPool>,
@@ -65,39 +63,19 @@ pub struct DedupService {
maintenance_pool: Arc<PgPool>,
}
/// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff").
/// Avoids a `format!("{:02x}", i)` allocation on every iteration of `initialize()`.
static HEX_PREFIXES: [&str; 256] = [
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f",
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", "1e", "1f",
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "2a", "2b", "2c", "2d", "2e", "2f",
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", "3c", "3d", "3e", "3f",
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f",
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "5a", "5b", "5c", "5d", "5e", "5f",
"60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", "6e", "6f",
"70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "7a", "7b", "7c", "7d", "7e", "7f",
"80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", "8c", "8d", "8e", "8f",
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f",
"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "aa", "ab", "ac", "ad", "ae", "af",
"b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", "be", "bf",
"c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf",
"d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", "dc", "dd", "de", "df",
"e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef",
"f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "fa", "fb", "fc", "fd", "fe", "ff",
];
impl DedupService {
/// Create a new dedup service backed by PostgreSQL.
///
/// * `backend` — pluggable blob storage (local filesystem, S3, etc.).
/// * `pool` — primary pool for request-path operations.
/// * `maintenance_pool` — isolated pool for verify_integrity / garbage_collect.
pub fn new(storage_root: &Path, pool: Arc<PgPool>, maintenance_pool: Arc<PgPool>) -> Self {
let blob_root = storage_root.join(".blobs");
let temp_root = storage_root.join(".dedup_temp");
pub fn new(
backend: Arc<dyn BlobStorageBackend>,
pool: Arc<PgPool>,
maintenance_pool: Arc<PgPool>,
) -> Self {
Self {
blob_root,
temp_root,
backend,
pool,
maintenance_pool,
}
@@ -106,6 +84,7 @@ impl DedupService {
/// Creates a stub instance for testing — never hits PG or the filesystem.
#[cfg(any(test, feature = "integration_tests"))]
pub fn new_stub() -> Self {
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
let stub_pool = Arc::new(
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
.max_connections(1)
@@ -113,29 +92,15 @@ impl DedupService {
.unwrap(),
);
Self {
blob_root: std::path::PathBuf::from("/tmp/oxicloud_stub_blobs"),
temp_root: std::path::PathBuf::from("/tmp/oxicloud_stub_temp"),
backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))),
pool: stub_pool.clone(),
maintenance_pool: stub_pool,
}
}
/// Initialize the service (create blob directories on the filesystem).
/// Initialize the service (delegate to backend + log stats from PG).
pub async fn initialize(&self) -> Result<(), DomainError> {
// Create directories
fs::create_dir_all(&self.blob_root)
.await
.map_err(DomainError::from)?;
fs::create_dir_all(&self.temp_root)
.await
.map_err(DomainError::from)?;
// Create hash prefix directories (00-ff)
for prefix in &HEX_PREFIXES {
fs::create_dir_all(self.blob_root.join(prefix))
.await
.map_err(DomainError::from)?;
}
self.backend.initialize().await?;
// Log existing blob stats from PG
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM storage.blobs")
@@ -150,7 +115,8 @@ impl DedupService {
.unwrap_or(0);
tracing::info!(
"Dedup service initialized (PostgreSQL-backed): {} blobs, {} bytes stored",
"Dedup service initialized (backend={}): {} blobs, {} bytes stored",
self.backend.backend_type(),
count,
total_bytes
);
@@ -158,12 +124,18 @@ impl DedupService {
Ok(())
}
/// Return a reference to the underlying blob storage backend.
pub fn backend(&self) -> &Arc<dyn BlobStorageBackend> {
&self.backend
}
// ── Path helpers ─────────────────────────────────────────────
/// Get the blob path for a given hash.
/// Get the local blob path for a given hash (if the backend supports it).
pub fn blob_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[0..2];
self.blob_root.join(prefix).join(format!("{}.blob", hash))
self.backend
.local_blob_path(hash)
.unwrap_or_else(|| PathBuf::from(format!("remote://{}", hash)))
}
// ── Hash helpers ─────────────────────────────────────────────
@@ -193,9 +165,9 @@ impl DedupService {
/// Store content with deduplication (streaming from file).
///
/// **Write-first strategy**: the source file is moved/copied to the
/// blob store *before* touching PostgreSQL, so the PG connection is
/// never held during disk I/O.
/// **Write-first strategy**: the source file is moved/uploaded to the
/// blob backend *before* touching PostgreSQL, so the PG connection is
/// never held during I/O.
///
/// If `pre_computed_hash` is `Some`, the file will NOT be re-read for
/// BLAKE3 — saving one full sequential read (the biggest I/O win).
@@ -205,13 +177,6 @@ impl DedupService {
content_type: Option<String>,
pre_computed_hash: Option<String>,
) -> Result<DedupResultDto, DomainError> {
let file_size = fs::metadata(source_path)
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("Failed to get file metadata: {}", e))
})?
.len();
// Use pre-computed hash if available, otherwise calculate (streaming)
let hash = match pre_computed_hash {
Some(h) => h,
@@ -220,44 +185,11 @@ impl DedupService {
.map_err(DomainError::from)?,
};
// ── Phase 1: Place blob in backend (NO PG connection held) ───
let file_size = self.backend.put_blob(&hash, source_path).await?;
let blob_path = self.blob_path(&hash);
// ── Phase 1: Move/place blob on disk (NO PG connection held) ─
//
// If the blob file already exists on disk, the source is simply
// deleted — the file content is identical by definition.
if fs::try_exists(&blob_path).await.unwrap_or(false) {
// Blob already on disk — discard the source file
let _ = fs::remove_file(source_path).await;
} else {
// Parent directory (xx/) guaranteed to exist — created by initialize()
// rename is atomic on the same filesystem. If source and blob
// dirs live on different filesystems (rare), this falls back to
// copy+delete which is slower but still correct.
if let Err(e) = fs::rename(source_path, &blob_path).await {
if e.raw_os_error() == Some(18) {
// EXDEV: cross-device link — fall back to copy+delete
fs::copy(source_path, &blob_path).await.map_err(|ce| {
DomainError::internal_error(
"Dedup",
format!("Failed to copy file to blob store: {}", ce),
)
})?;
let _ = fs::remove_file(source_path).await;
} else if fs::try_exists(&blob_path).await.unwrap_or(false) {
// Another writer may have placed the blob concurrently
let _ = fs::remove_file(source_path).await;
tracing::debug!("Blob file placed by concurrent writer: {}", e);
} else {
return Err(DomainError::internal_error(
"Dedup",
format!("Failed to move file to blob store: {}", e),
));
}
}
}
// ── Phase 2: Single atomic upsert (~2-4 ms, no explicit TX) ─
let ref_count: i32 = sqlx::query_scalar(
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
@@ -415,10 +347,9 @@ impl DedupService {
DomainError::internal_error("Dedup", format!("Failed to commit: {}", e))
})?;
// Delete blob file AFTER committing PG — the row is gone, so no
// concurrent store_from_file can resurrect a reference to this hash.
let blob_path = self.blob_path(hash);
if let Err(e) = fs::remove_file(&blob_path).await {
// Delete blob from backend AFTER committing PG — the row is gone,
// so no concurrent store_from_file can resurrect a reference.
if let Err(e) = self.backend.delete_blob(hash).await {
tracing::warn!("Failed to delete blob file {}: {}", hash, e);
}
@@ -449,31 +380,16 @@ impl DedupService {
// ── Read operations ──────────────────────────────────────────
/// Stream blob content in 64 KB chunks — constant memory (~64 KB per stream).
///
/// A 1 GB file uses the same ~64 KB as a 1 KB file.
/// Stream blob content in chunks — constant memory usage.
pub async fn read_blob_stream(
&self,
hash: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>
{
let blob_path = self.blob_path(hash);
let file = File::open(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to open blob {}: {}", hash, e),
)
})?;
Ok(Box::pin(ReaderStream::with_capacity(
file,
STREAM_CHUNK_SIZE,
)))
self.backend.get_blob_stream(hash).await
}
/// Stream a byte range of a blob — only reads the requested portion.
///
/// Uses seek + take so a 1 MB range request on a 1 GB file only reads 1 MB.
pub async fn read_blob_range_stream(
&self,
hash: &str,
@@ -481,49 +397,12 @@ impl DedupService {
end: Option<u64>,
) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, DomainError>
{
let blob_path = self.blob_path(hash);
let mut file = File::open(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to open blob {}: {}", hash, e),
)
})?;
// Seek to the start position
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| {
DomainError::internal_error("Blob", format!("Failed to seek in blob: {}", e))
})?;
// If an end is specified, limit the read with take()
if let Some(end_pos) = end {
let limit = end_pos.saturating_sub(start);
let limited = file.take(limit);
Ok(Box::pin(ReaderStream::with_capacity(
limited,
STREAM_CHUNK_SIZE,
)))
} else {
Ok(Box::pin(ReaderStream::with_capacity(
file,
STREAM_CHUNK_SIZE,
)))
}
self.backend.get_blob_range_stream(hash, start, end).await
}
/// Get the size of a blob without reading its content.
pub async fn blob_size(&self, hash: &str) -> Result<u64, DomainError> {
let blob_path = self.blob_path(hash);
let meta = fs::metadata(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to stat blob {}: {}", hash, e),
)
})?;
Ok(meta.len())
self.backend.blob_size(hash).await
}
// ── Statistics (computed from PG) ────────────────────────────
@@ -596,56 +475,46 @@ impl DedupService {
// Flush when batch is full or we've exhausted the cursor
if batch.len() >= VERIFY_CONCURRENCY || (is_done && !batch.is_empty()) {
let blob_root = self.blob_root.clone();
let backend = self.backend.clone();
let current_batch =
std::mem::replace(&mut batch, Vec::with_capacity(VERIFY_CONCURRENCY));
let issues: Vec<String> = stream::iter(current_batch)
.map(move |(hash, expected_size)| {
let blob_root = blob_root.clone();
let backend = backend.clone();
async move {
let prefix = &hash[0..2];
let blob_path = blob_root.join(prefix).join(format!("{}.blob", hash));
let mut issues = Vec::new();
// Single async metadata() replaces the previous
// blocking .exists() + separate metadata() — one
// stat() syscall instead of two, and non-blocking.
let file_meta = match fs::metadata(&blob_path).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
issues.push(format!("{}: file missing on disk", hash));
return issues;
// Check existence + size via backend
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,
));
}
}
Err(e) => {
issues.push(format!("{}: metadata error ({})", hash, e));
Err(_) => {
issues.push(format!("{}: blob missing in backend", hash));
return issues;
}
};
// Check size
if file_meta.len() != expected_size as u64 {
issues.push(format!(
"{}: size mismatch (expected: {}, actual: {})",
hash,
expected_size,
file_meta.len(),
));
}
// Verify hash
match Self::hash_file(&blob_path).await {
Ok(actual_hash) => {
if actual_hash != hash {
issues.push(format!(
"{}: hash mismatch (actual: {})",
hash, actual_hash,
));
// Verify hash — only possible for local backends
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));
}
}
Err(e) => {
issues.push(format!("{}: read error ({})", hash, e));
}
}
@@ -717,20 +586,20 @@ impl DedupService {
// Also clean up any thumbnail files for these blob hashes
// (thumbnails are keyed by blob_hash and live under
// storage_root/.thumbnails/{icon,preview,large}/{hash}.jpg).
let thumbnails_root = self
.blob_root
.parent()
.unwrap_or(&self.blob_root)
.join(".thumbnails");
for (hash, size) in &batch {
let blob_path = self.blob_path(hash);
if let Err(e) = fs::remove_file(&blob_path).await {
if let Err(e) = self.backend.delete_blob(hash).await {
tracing::warn!("Failed to delete orphan blob file {hash}: {e}");
}
// Remove associated thumbnail files (best-effort)
for dir in &["icon", "preview", "large"] {
let thumb = thumbnails_root.join(dir).join(format!("{hash}.jpg"));
let _ = fs::remove_file(&thumb).await;
// Remove associated thumbnail files (best-effort, always local)
if let Some(blob_path) = self.backend.local_blob_path(hash)
&& let Some(storage_root) = blob_path.ancestors().nth(3)
{
let thumbnails_root = storage_root.join(".thumbnails");
for dir in &["icon", "preview", "large"] {
let thumb = thumbnails_root.join(dir).join(format!("{hash}.jpg"));
let _ = fs::remove_file(&thumb).await;
}
}
total_bytes += *size as u64;
}
@@ -0,0 +1,298 @@
//! `EncryptedBlobBackend` — AES-256-GCM encryption decorator for blob storage.
//!
//! Transparently encrypts blobs before they reach the inner backend and
//! decrypts them on read. Each blob gets a random 96-bit nonce which is
//! prepended to the ciphertext. The GCM authentication tag (16 bytes) is
//! appended by the cipher.
//!
//! **IMPORTANT**: BLAKE3 hashing is performed on the *plaintext* by
//! `DedupService` before this layer sees the blob, so content-addressable
//! dedup still works correctly.
//!
//! Layout on disk/S3: `[12-byte nonce][ciphertext + 16-byte GCM tag]`
use std::path::{Path, PathBuf};
use std::pin::Pin;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
use bytes::Bytes;
use std::sync::Arc;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::domain::errors::DomainError;
/// Nonce size for AES-256-GCM (96 bits = 12 bytes).
const NONCE_SIZE: usize = 12;
/// `BlobStorageBackend` decorator that encrypts blobs at rest.
pub struct EncryptedBlobBackend {
inner: Arc<dyn BlobStorageBackend>,
cipher: Aes256Gcm,
}
impl EncryptedBlobBackend {
/// Create a new encryption layer wrapping `inner`.
///
/// `key` must be exactly 32 bytes (AES-256).
pub fn new(inner: Arc<dyn BlobStorageBackend>, key: &[u8; 32]) -> Self {
let cipher = Aes256Gcm::new_from_slice(key).expect("AES-256 key must be 32 bytes");
Self { inner, cipher }
}
/// Generate a random 32-byte key suitable for AES-256.
pub fn generate_key() -> [u8; 32] {
use aes_gcm::aead::rand_core::RngCore;
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
}
impl BlobStorageBackend for EncryptedBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
self.inner.initialize()
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let source = source_path.to_path_buf();
// Clone cipher key material (Aes256Gcm is not Send-safe to move across await)
let cipher = self.cipher.clone();
Box::pin(async move {
// Read plaintext from source
let plaintext = fs::read(&source).await.map_err(|e| {
DomainError::internal_error("Encryption", format!("read source: {e}"))
})?;
// Encrypt: nonce || ciphertext (includes GCM tag)
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, plaintext.as_ref()).map_err(|e| {
DomainError::internal_error("Encryption", format!("encrypt failed: {e}"))
})?;
// Write encrypted blob to a temp file
let tmp = source.with_extension("enc.tmp");
let mut file = fs::File::create(&tmp).await.map_err(|e| {
DomainError::internal_error("Encryption", format!("create tmp: {e}"))
})?;
file.write_all(nonce.as_slice()).await.map_err(|e| {
DomainError::internal_error("Encryption", format!("write nonce: {e}"))
})?;
file.write_all(&ciphertext).await.map_err(|e| {
DomainError::internal_error("Encryption", format!("write ciphertext: {e}"))
})?;
file.flush()
.await
.map_err(|e| DomainError::internal_error("Encryption", format!("flush: {e}")))?;
drop(file);
let result = inner.put_blob(&hash, &tmp).await;
let _ = fs::remove_file(&tmp).await;
result
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
// Read entire encrypted blob (nonce + ciphertext) into memory for decryption
let enc_stream = inner.get_blob_stream(&hash).await?;
let encrypted = collect_stream(enc_stream).await?;
if encrypted.len() < NONCE_SIZE {
return Err(DomainError::internal_error(
"Encryption",
"encrypted blob too short (missing nonce)",
));
}
let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| {
DomainError::internal_error("Encryption", format!("decrypt failed: {e}"))
})?;
let stream: BlobStream =
Box::pin(futures::stream::once(
async move { Ok(Bytes::from(plaintext)) },
));
Ok(stream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
// Must decrypt the full blob then slice the plaintext range
let enc_stream = inner.get_blob_stream(&hash).await?;
let encrypted = collect_stream(enc_stream).await?;
if encrypted.len() < NONCE_SIZE {
return Err(DomainError::internal_error(
"Encryption",
"encrypted blob too short",
));
}
let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| {
DomainError::internal_error("Encryption", format!("decrypt failed: {e}"))
})?;
let start = start as usize;
let end = end.map(|e| (e as usize) + 1).unwrap_or(plaintext.len());
let end = end.min(plaintext.len());
let start = start.min(end);
let slice = Bytes::from(plaintext[start..end].to_vec());
let stream: BlobStream = Box::pin(futures::stream::once(async move { Ok(slice) }));
Ok(stream)
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
self.inner.delete_blob(hash)
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
self.inner.blob_exists(hash)
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
// The stored size includes nonce + GCM tag overhead.
// Return the *plaintext* size by subtracting overhead.
let inner = self.inner.clone();
let hash = hash.to_string();
Box::pin(async move {
let encrypted_size = inner.blob_size(&hash).await?;
// overhead = 12 (nonce) + 16 (GCM tag) = 28 bytes
Ok(encrypted_size.saturating_sub(28))
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
let inner = self.inner.clone();
Box::pin(async move {
let mut status = inner.health_check().await?;
status.backend_type = format!("encrypted({})", status.backend_type);
status.message = format!("{} | Encryption: AES-256-GCM", status.message);
Ok(status)
})
}
fn backend_type(&self) -> &'static str {
"encrypted"
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
// Encrypted blobs cannot be served directly from disk
None
}
}
/// Collect a byte stream into a single `Vec<u8>`.
async fn collect_stream(stream: BlobStream) -> Result<Vec<u8>, DomainError> {
use futures::StreamExt;
let mut stream = stream;
let mut buf = Vec::new();
while let Some(chunk) = stream.next().await {
let bytes = chunk
.map_err(|e| DomainError::internal_error("Encryption", format!("stream read: {e}")))?;
buf.extend_from_slice(&bytes);
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn test_encrypt_decrypt_roundtrip() {
let tmp = TempDir::new().unwrap();
let blob_dir = tmp.path().join("blobs");
let local = Arc::new(LocalBlobBackend::new(&blob_dir));
local.initialize().await.unwrap();
let key = EncryptedBlobBackend::generate_key();
let encrypted = EncryptedBlobBackend::new(local, &key);
// Write a test blob
let data = b"Hello, encrypted world!";
let source = tmp.path().join("test.tmp");
let mut f = fs::File::create(&source).await.unwrap();
f.write_all(data).await.unwrap();
f.flush().await.unwrap();
drop(f);
let hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
encrypted.put_blob(hash, &source).await.unwrap();
// Read back via stream
let stream = encrypted.get_blob_stream(hash).await.unwrap();
let decrypted = collect_stream(stream).await.unwrap();
assert_eq!(decrypted, data);
// Read range
let range_stream = encrypted
.get_blob_range_stream(hash, 7, Some(15))
.await
.unwrap();
let range_data = collect_stream(range_stream).await.unwrap();
assert_eq!(range_data, b"encrypted");
// Size should reflect plaintext
let size = encrypted.blob_size(hash).await.unwrap();
assert_eq!(size, data.len() as u64);
// Exists
assert!(encrypted.blob_exists(hash).await.unwrap());
// Delete
encrypted.delete_blob(hash).await.unwrap();
assert!(!encrypted.blob_exists(hash).await.unwrap());
}
}
@@ -0,0 +1,277 @@
//! Local Filesystem Blob Backend — stores blobs under `.blobs/{prefix}/{hash}.blob`.
//!
//! This is the default backend and a direct extraction of the filesystem I/O
//! that previously lived inside `DedupService`.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use tokio::fs::{self, File};
use tokio::io::AsyncSeekExt;
use tokio_util::io::ReaderStream;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Chunk size for streaming file reads (256 KB).
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
/// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff").
static HEX_PREFIXES: [&str; 256] = [
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f",
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", "1e", "1f",
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "2a", "2b", "2c", "2d", "2e", "2f",
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", "3c", "3d", "3e", "3f",
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f",
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "5a", "5b", "5c", "5d", "5e", "5f",
"60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", "6e", "6f",
"70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "7a", "7b", "7c", "7d", "7e", "7f",
"80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", "8c", "8d", "8e", "8f",
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f",
"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "aa", "ab", "ac", "ad", "ae", "af",
"b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", "be", "bf",
"c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf",
"d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", "dc", "dd", "de", "df",
"e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef",
"f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "fa", "fb", "fc", "fd", "fe", "ff",
];
/// Local filesystem blob backend.
///
/// Blobs are stored under `blob_root/{2-char-prefix}/{hash}.blob`.
/// Temporary upload staging uses `temp_root/`.
pub struct LocalBlobBackend {
blob_root: PathBuf,
temp_root: PathBuf,
}
impl LocalBlobBackend {
/// Create a new local backend rooted at `storage_root`.
///
/// Blob files go under `{storage_root}/.blobs/`, temp files under
/// `{storage_root}/.dedup_temp/`.
pub fn new(storage_root: &Path) -> Self {
Self {
blob_root: storage_root.join(".blobs"),
temp_root: storage_root.join(".dedup_temp"),
}
}
/// Compute the filesystem path for a blob hash.
pub fn blob_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[0..2];
self.blob_root.join(prefix).join(format!("{}.blob", hash))
}
/// Return a reference to the blob root directory.
pub fn blob_root(&self) -> &Path {
&self.blob_root
}
}
impl BlobStorageBackend for LocalBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
Box::pin(async move {
fs::create_dir_all(&self.blob_root)
.await
.map_err(DomainError::from)?;
fs::create_dir_all(&self.temp_root)
.await
.map_err(DomainError::from)?;
// Create the 256 hash-prefix directories (00-ff)
for prefix in &HEX_PREFIXES {
fs::create_dir_all(self.blob_root.join(prefix))
.await
.map_err(DomainError::from)?;
}
Ok(())
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
let source_path = source_path.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error(
"Blob",
format!("Failed to stat source file: {}", e),
)
})?
.len();
// Idempotent: if blob already exists, just remove the source
if fs::try_exists(&blob_path).await.unwrap_or(false) {
let _ = fs::remove_file(&source_path).await;
return Ok(file_size);
}
// Atomic rename (same filesystem). Falls back to copy+delete for
// cross-device moves (EXDEV errno 18).
if let Err(e) = fs::rename(&source_path, &blob_path).await {
if e.raw_os_error() == Some(18) {
// EXDEV — cross-device link
fs::copy(&source_path, &blob_path).await.map_err(|ce| {
DomainError::internal_error(
"Blob",
format!("Failed to copy file to blob store: {}", ce),
)
})?;
let _ = fs::remove_file(&source_path).await;
} else if fs::try_exists(&blob_path).await.unwrap_or(false) {
// Concurrent writer placed the blob — discard our copy
let _ = fs::remove_file(&source_path).await;
tracing::debug!("Blob placed by concurrent writer: {}", e);
} else {
return Err(DomainError::internal_error(
"Blob",
format!("Failed to move file to blob store: {}", e),
));
}
}
Ok(file_size)
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
let file = File::open(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to open blob {}: {}", hash, e),
)
})?;
Ok(Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) as BlobStream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
let mut file = File::open(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to open blob {}: {}", hash, e),
)
})?;
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| {
DomainError::internal_error("Blob", format!("Failed to seek in blob: {}", e))
})?;
if let Some(end_pos) = end {
use tokio::io::AsyncReadExt;
let limit = end_pos.saturating_sub(start);
let limited = file.take(limit);
Ok(Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE)) as BlobStream)
} else {
Ok(Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) as BlobStream)
}
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
match fs::remove_file(&blob_path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), // idempotent
Err(e) => Err(DomainError::internal_error(
"Blob",
format!("Failed to delete blob {}: {}", hash, e),
)),
}
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
Ok(fs::try_exists(&blob_path).await.unwrap_or(false))
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
let meta = fs::metadata(&blob_path).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Blob",
format!("Failed to stat blob {}: {}", hash, e),
)
})?;
Ok(meta.len())
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
Box::pin(async move {
let writable = fs::metadata(&self.blob_root).await.is_ok();
Ok(StorageHealthStatus {
connected: writable,
backend_type: "local".to_string(),
message: if writable {
"Local filesystem is accessible".to_string()
} else {
"Blob root directory is not accessible".to_string()
},
available_bytes: None,
})
})
}
fn backend_type(&self) -> &'static str {
"local"
}
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
Some(self.blob_path(hash))
}
}
@@ -0,0 +1,204 @@
//! `MigrationBlobBackend` — decorator that enables zero-downtime migration
//! between blob storage backends.
//!
//! During a migration the decorator writes to the **target** backend and reads
//! from **target-first-then-source** (dual-read). A background job
//! (see `migration_job.rs`) copies remaining blobs in the background.
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::RwLock;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::common::errors::DomainError;
// ── Migration state ────────────────────────────────────────────────
/// Progress of an ongoing (or completed) backend migration.
#[derive(Debug, Clone, Serialize)]
pub struct MigrationState {
pub status: MigrationStatus,
pub total_blobs: u64,
pub migrated_blobs: u64,
pub migrated_bytes: u64,
pub failed_blobs: Vec<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
impl Default for MigrationState {
fn default() -> Self {
Self {
status: MigrationStatus::Idle,
total_blobs: 0,
migrated_blobs: 0,
migrated_bytes: 0,
failed_blobs: Vec::new(),
started_at: None,
completed_at: None,
}
}
}
/// Status of the migration job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationStatus {
Idle,
Running,
Paused,
Completed,
Failed,
}
// ── MigrationBlobBackend ───────────────────────────────────────────
/// A `BlobStorageBackend` decorator that proxies requests to a *source*
/// (old) and *target* (new) backend, enabling live migration.
pub struct MigrationBlobBackend {
source: Arc<dyn BlobStorageBackend>,
target: Arc<dyn BlobStorageBackend>,
state: Arc<RwLock<MigrationState>>,
}
impl MigrationBlobBackend {
pub fn new(
source: Arc<dyn BlobStorageBackend>,
target: Arc<dyn BlobStorageBackend>,
state: Arc<RwLock<MigrationState>>,
) -> Self {
Self {
source,
target,
state,
}
}
pub fn state(&self) -> &Arc<RwLock<MigrationState>> {
&self.state
}
pub fn source(&self) -> &Arc<dyn BlobStorageBackend> {
&self.source
}
pub fn target(&self) -> &Arc<dyn BlobStorageBackend> {
&self.target
}
}
/// Boxed future alias (same as in the trait module).
type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
impl BlobStorageBackend for MigrationBlobBackend {
fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>> {
Box::pin(async move {
self.target.initialize().await?;
// Source is already initialised; call anyway for idempotency.
self.source.initialize().await?;
Ok(())
})
}
/// Writes go to **target** only.
fn put_blob(&self, hash: &str, source_path: &Path) -> BoxFut<'_, Result<u64, DomainError>> {
let hash = hash.to_string();
let path = source_path.to_path_buf();
Box::pin(async move { self.target.put_blob(&hash, &path).await })
}
/// Read from target first; fall back to source.
fn get_blob_stream(&self, hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>> {
let hash = hash.to_string();
Box::pin(async move {
match self.target.get_blob_stream(&hash).await {
Ok(stream) => Ok(stream),
Err(_) => self.source.get_blob_stream(&hash).await,
}
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> BoxFut<'_, Result<BlobStream, DomainError>> {
let hash = hash.to_string();
Box::pin(async move {
match self.target.get_blob_range_stream(&hash, start, end).await {
Ok(stream) => Ok(stream),
Err(_) => self.source.get_blob_range_stream(&hash, start, end).await,
}
})
}
/// Delete from **both** backends (best-effort on source).
fn delete_blob(&self, hash: &str) -> BoxFut<'_, Result<(), DomainError>> {
let hash = hash.to_string();
Box::pin(async move {
self.target.delete_blob(&hash).await?;
// Best-effort on source — ignore errors (blob may already be gone).
let _ = self.source.delete_blob(&hash).await;
Ok(())
})
}
/// Exists in either backend.
fn blob_exists(&self, hash: &str) -> BoxFut<'_, Result<bool, DomainError>> {
let hash = hash.to_string();
Box::pin(async move {
if self.target.blob_exists(&hash).await? {
return Ok(true);
}
self.source.blob_exists(&hash).await
})
}
fn blob_size(&self, hash: &str) -> BoxFut<'_, Result<u64, DomainError>> {
let hash = hash.to_string();
Box::pin(async move {
match self.target.blob_size(&hash).await {
Ok(sz) => Ok(sz),
Err(_) => self.source.blob_size(&hash).await,
}
})
}
fn health_check(&self) -> BoxFut<'_, Result<StorageHealthStatus, DomainError>> {
Box::pin(async move {
let target_health = self.target.health_check().await?;
let source_health = self.source.health_check().await?;
Ok(StorageHealthStatus {
connected: target_health.connected && source_health.connected,
backend_type: format!(
"migration({} → {})",
source_health.backend_type, target_health.backend_type
),
message: format!(
"Source: {} | Target: {}",
source_health.message, target_health.message
),
available_bytes: target_health.available_bytes,
})
})
}
fn backend_type(&self) -> &'static str {
"migration"
}
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
// Prefer target, fall back to source.
self.target
.local_blob_path(hash)
.or_else(|| self.source.local_blob_path(hash))
}
}
@@ -0,0 +1,240 @@
//! Background migration job — copies blobs from a source backend to a target
//! backend with configurable concurrency and progress tracking.
use std::sync::Arc;
use futures::StreamExt;
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::RwLock;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::common::errors::DomainError;
use crate::infrastructure::services::migration_blob_backend::{MigrationState, MigrationStatus};
/// Run the migration: stream all blob hashes from `storage.blobs` and copy
/// each one from `source` to `target`.
///
/// * The job respects `Paused` / `Failed` status in `state` — it will stop
/// streaming when the status is no longer `Running`.
/// * Errors on individual blobs are logged and collected in `failed_blobs`
/// but do **not** abort the full run.
/// * `concurrency` controls `buffer_unordered` parallelism (default: 4).
pub async fn run_migration(
source: Arc<dyn BlobStorageBackend>,
target: Arc<dyn BlobStorageBackend>,
pool: Arc<PgPool>,
state: Arc<RwLock<MigrationState>>,
concurrency: usize,
) -> Result<(), DomainError> {
// Count total blobs for progress tracking.
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM storage.blobs")
.fetch_one(pool.as_ref())
.await
.unwrap_or(0);
{
let mut s = state.write().await;
s.status = MigrationStatus::Running;
s.total_blobs = total as u64;
s.migrated_blobs = 0;
s.migrated_bytes = 0;
s.failed_blobs.clear();
s.started_at = Some(chrono::Utc::now());
s.completed_at = None;
}
// Stream all hashes+sizes with a cursor.
let mut rows =
sqlx::query_as::<_, (String, i64)>("SELECT hash, size FROM storage.blobs ORDER BY hash")
.fetch(pool.as_ref());
// Collect all hashes first to avoid holding the cursor across awaits.
let mut work: Vec<(String, i64)> = Vec::with_capacity(total as usize);
while let Some(row) = rows.next().await {
match row {
Ok(r) => work.push(r),
Err(e) => {
tracing::warn!("Error fetching blob row during migration: {}", e);
}
}
}
// Process in parallel chunks.
let results = futures::stream::iter(work.into_iter().map(|(hash, size)| {
let src = source.clone();
let tgt = target.clone();
let st = state.clone();
async move {
// Check if we should keep running.
{
let s = st.read().await;
if s.status != MigrationStatus::Running {
return;
}
}
// Skip if already in target.
match tgt.blob_exists(&hash).await {
Ok(true) => {
let mut s = st.write().await;
s.migrated_blobs += 1;
s.migrated_bytes += size as u64;
return;
}
Ok(false) => {}
Err(e) => {
tracing::warn!("blob_exists check failed for {}: {}", hash, e);
}
}
// Copy: stream from source → temp file → put into target.
if let Err(e) = copy_blob(&src, &tgt, &hash).await {
tracing::warn!("Failed to migrate blob {}: {}", hash, e);
let mut s = st.write().await;
s.failed_blobs.push(hash);
return;
}
let mut s = st.write().await;
s.migrated_blobs += 1;
s.migrated_bytes += size as u64;
}
}))
.buffer_unordered(concurrency)
.collect::<Vec<()>>()
.await;
drop(results);
// Finalize state.
let mut s = state.write().await;
if s.status == MigrationStatus::Running {
if s.failed_blobs.is_empty() {
s.status = MigrationStatus::Completed;
} else {
s.status = MigrationStatus::Failed;
}
s.completed_at = Some(chrono::Utc::now());
}
tracing::info!(
"Migration finished: {}/{} blobs, {} failures",
s.migrated_blobs,
s.total_blobs,
s.failed_blobs.len()
);
Ok(())
}
/// Copy a single blob: stream from source → spool to temp file → put_blob into target.
async fn copy_blob(
source: &Arc<dyn BlobStorageBackend>,
target: &Arc<dyn BlobStorageBackend>,
hash: &str,
) -> Result<(), DomainError> {
use tokio::io::AsyncWriteExt;
// Create a temp file to spool content.
let tmp_dir = std::env::temp_dir().join("oxicloud-migration");
tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| {
DomainError::internal_error("Migration", format!("Failed to create temp dir: {}", e))
})?;
let tmp_path = tmp_dir.join(format!("{}.tmp", hash));
// Stream from source.
let stream = source.get_blob_stream(hash).await?;
// Write to temp file.
let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
DomainError::internal_error("Migration", format!("Failed to create temp file: {}", e))
})?;
let mut stream = std::pin::pin!(stream);
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(|e| {
DomainError::internal_error("Migration", format!("Stream error: {}", e))
})?;
file.write_all(&bytes)
.await
.map_err(|e| DomainError::internal_error("Migration", format!("Write error: {}", e)))?;
}
file.flush()
.await
.map_err(|e| DomainError::internal_error("Migration", format!("Flush error: {}", e)))?;
drop(file);
// Put into target.
target.put_blob(hash, &tmp_path).await?;
// Clean up temp file.
let _ = tokio::fs::remove_file(&tmp_path).await;
Ok(())
}
/// Verify migration integrity by comparing blob counts and sampling random hashes.
pub async fn verify_migration(
target: Arc<dyn BlobStorageBackend>,
pool: Arc<PgPool>,
sample_size: usize,
) -> Result<VerificationResult, DomainError> {
// 1. Count blobs in PG.
let pg_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM storage.blobs")
.fetch_one(pool.as_ref())
.await
.unwrap_or(0);
// 2. Verify sample of blobs exist in target.
let sample_rows: Vec<(String, i64)> =
sqlx::query_as("SELECT hash, size FROM storage.blobs ORDER BY random() LIMIT $1")
.bind(sample_size as i64)
.fetch_all(pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("Migration", format!("Sample query failed: {}", e))
})?;
let mut missing = Vec::new();
let mut size_mismatches = Vec::new();
for (hash, expected_size) in &sample_rows {
match target.blob_exists(hash).await {
Ok(false) => missing.push(hash.clone()),
Err(e) => {
tracing::warn!("blob_exists failed for {}: {}", hash, e);
missing.push(hash.clone());
}
Ok(true) => {
// Verify size matches.
if let Ok(actual_size) = target.blob_size(hash).await
&& actual_size != *expected_size as u64
{
size_mismatches.push(hash.clone());
}
}
}
}
let passed = missing.is_empty() && size_mismatches.is_empty();
Ok(VerificationResult {
pg_blob_count: pg_count as u64,
sample_checked: sample_rows.len() as u64,
missing_in_target: missing,
size_mismatches,
passed,
})
}
/// Result of a post-migration integrity check.
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct VerificationResult {
pub pg_blob_count: u64,
pub sample_checked: u64,
pub missing_in_target: Vec<String>,
pub size_mismatches: Vec<String>,
pub passed: bool,
}
+8
View File
@@ -1,18 +1,26 @@
pub mod audio_metadata_service;
pub mod azure_blob_backend;
pub mod cached_blob_backend;
pub mod chunked_upload_service;
pub mod compression_service;
pub mod dedup_service;
pub mod encrypted_blob_backend;
pub mod exif_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod image_transcode_service;
pub mod jwt_service;
pub mod local_blob_backend;
pub mod login_lockout_service;
pub mod migration_blob_backend;
pub mod migration_job;
pub mod nextcloud_chunked_upload_service;
pub mod oidc_service;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod thumbnail_service;
#[cfg(test)]
mod thumbnail_service_test;
@@ -0,0 +1,254 @@
//! `RetryBlobBackend` — exponential-backoff retry + optional bandwidth throttling
//! decorator for remote blob backends.
//!
//! Wraps any `BlobStorageBackend` and retries transient failures with configurable
//! exponential backoff. Optionally throttles upload/download bandwidth via
//! inter-chunk sleeps.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::domain::errors::DomainError;
// ── Retry policy ───────────────────────────────────────────────────
/// Exponential backoff retry configuration.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// Maximum number of retry attempts (0 = no retries).
pub max_retries: u32,
/// Initial backoff duration before the first retry.
pub initial_backoff: Duration,
/// Maximum backoff duration (capped).
pub max_backoff: Duration,
/// Multiplier applied to backoff after each attempt.
pub backoff_multiplier: f64,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(10),
backoff_multiplier: 2.0,
}
}
}
// ── RetryBlobBackend ───────────────────────────────────────────────
/// Decorator that retries failed backend operations with exponential backoff.
pub struct RetryBlobBackend {
inner: Arc<dyn BlobStorageBackend>,
policy: RetryPolicy,
}
impl RetryBlobBackend {
pub fn new(inner: Arc<dyn BlobStorageBackend>, policy: RetryPolicy) -> Self {
Self { inner, policy }
}
}
/// Execute an async closure with exponential backoff retry.
async fn retry_async<F, Fut, T>(
policy: &RetryPolicy,
name: &str,
mut f: F,
) -> Result<T, DomainError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, DomainError>>,
{
let mut attempt = 0u32;
let mut backoff = policy.initial_backoff;
loop {
match f().await {
Ok(v) => return Ok(v),
Err(e) if attempt < policy.max_retries && is_retryable(&e) => {
attempt += 1;
tracing::warn!(
"Retry {}/{} for {} after error: {} (backoff {:?})",
attempt,
policy.max_retries,
name,
e,
backoff
);
tokio::time::sleep(backoff).await;
let next =
Duration::from_secs_f64(backoff.as_secs_f64() * policy.backoff_multiplier);
backoff = next.min(policy.max_backoff);
}
Err(e) => return Err(e),
}
}
}
/// Determine if an error is likely transient (network timeout, 5xx, etc.).
fn is_retryable(err: &DomainError) -> bool {
let msg = err.to_string().to_lowercase();
msg.contains("timeout")
|| msg.contains("connection")
|| msg.contains("503")
|| msg.contains("500")
|| msg.contains("429")
|| msg.contains("temporarily")
|| msg.contains("broken pipe")
|| msg.contains("reset by peer")
}
impl BlobStorageBackend for RetryBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let policy = self.policy.clone();
Box::pin(async move {
retry_async(&policy, "initialize", || {
let inner = inner.clone();
async move { inner.initialize().await }
})
.await
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
let path = source_path.to_path_buf();
Box::pin(async move {
retry_async(&policy, &format!("put_blob({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
let path = path.clone();
async move { inner.put_blob(&hash, &path).await }
})
.await
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
Box::pin(async move {
retry_async(&policy, &format!("get_blob_stream({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
async move { inner.get_blob_stream(&hash).await }
})
.await
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
Box::pin(async move {
retry_async(&policy, &format!("get_blob_range({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
async move { inner.get_blob_range_stream(&hash, start, end).await }
})
.await
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
Box::pin(async move {
retry_async(&policy, &format!("delete_blob({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
async move { inner.delete_blob(&hash).await }
})
.await
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
Box::pin(async move {
retry_async(&policy, &format!("blob_exists({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
async move { inner.blob_exists(&hash).await }
})
.await
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let policy = self.policy.clone();
let hash = hash.to_string();
Box::pin(async move {
retry_async(&policy, &format!("blob_size({hash})"), || {
let inner = inner.clone();
let hash = hash.clone();
async move { inner.blob_size(&hash).await }
})
.await
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
let inner = self.inner.clone();
let policy = self.policy.clone();
Box::pin(async move {
retry_async(&policy, "health_check", || {
let inner = inner.clone();
async move { inner.health_check().await }
})
.await
})
}
fn backend_type(&self) -> &'static str {
"retry"
}
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
self.inner.local_blob_path(hash)
}
}
@@ -0,0 +1,344 @@
//! S3-Compatible Blob Backend — stores blobs in any S3-compatible object store.
//!
//! Supports AWS S3, Backblaze B2, Cloudflare R2, MinIO, DigitalOcean Spaces,
//! Wasabi, and any other service that implements the S3 API.
use aws_sdk_s3::primitives::ByteStream;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use tokio::fs;
use tokio_util::io::ReaderStream;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::common::config::S3StorageConfig;
use crate::domain::errors::{DomainError, ErrorKind};
/// S3-compatible blob storage backend.
///
/// Blobs are stored as objects with key `{2-char-prefix}/{hash}.blob`,
/// mirroring the local filesystem layout for consistency.
pub struct S3BlobBackend {
client: aws_sdk_s3::Client,
bucket: String,
}
impl S3BlobBackend {
/// Build a new S3 backend from configuration.
///
/// Supports custom endpoints for non-AWS providers (Backblaze B2,
/// MinIO, Cloudflare R2, etc.).
pub fn new(config: &S3StorageConfig) -> Self {
let credentials = aws_sdk_s3::config::Credentials::new(
&config.access_key,
&config.secret_key,
None,
None,
"oxicloud",
);
let mut builder = aws_sdk_s3::config::Builder::new()
.region(aws_sdk_s3::config::Region::new(config.region.clone()))
.credentials_provider(credentials)
.behavior_version_latest();
if let Some(ref endpoint) = config.endpoint_url {
builder = builder.endpoint_url(endpoint);
}
if config.force_path_style {
builder = builder.force_path_style(true);
}
let client = aws_sdk_s3::Client::from_conf(builder.build());
Self {
client,
bucket: config.bucket.clone(),
}
}
/// Compute the S3 object key for a given hash.
fn object_key(hash: &str) -> String {
let prefix = &hash[0..2];
format!("{}/{}.blob", prefix, hash)
}
}
impl BlobStorageBackend for S3BlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
Box::pin(async move {
// Verify bucket exists and is accessible
self.client
.head_bucket()
.bucket(&self.bucket)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Cannot access bucket '{}': {}", self.bucket, e),
)
})?;
tracing::info!("S3 blob backend initialized: bucket={}", self.bucket);
Ok(())
})
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
let source_path = source_path.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
// Check if object already exists (idempotent)
let exists = self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.is_ok();
if exists {
// Blob already in S3 — remove local source and return size
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to stat source file: {}", e),
)
})?
.len();
let _ = fs::remove_file(&source_path).await;
return Ok(file_size);
}
// Upload from local file
let body = ByteStream::from_path(&source_path).await.map_err(|e| {
DomainError::internal_error("S3", format!("Failed to read source file: {}", e))
})?;
let file_size = fs::metadata(&source_path)
.await
.map_err(|e| {
DomainError::internal_error("S3", format!("Failed to stat source file: {}", e))
})?
.len();
self.client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to upload blob {}: {}", hash, e),
)
})?;
// Clean up local source after successful upload
let _ = fs::remove_file(&source_path).await;
Ok(file_size)
})
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let output = self
.client
.get_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to get blob {}: {}", hash, e),
)
})?;
// Convert S3 ByteStream into a Stream<Item = Result<Bytes, io::Error>>
// via AsyncRead adapter
let reader = output.body.into_async_read();
Ok(Box::pin(ReaderStream::with_capacity(reader, 256 * 1024)) as BlobStream)
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let range = match end {
Some(end_pos) => format!("bytes={}-{}", start, end_pos.saturating_sub(1)),
None => format!("bytes={}-", start),
};
let output = self
.client
.get_object()
.bucket(&self.bucket)
.key(&key)
.range(range)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to get blob range {}: {}", hash, e),
)
})?;
let reader = output.body.into_async_read();
Ok(Box::pin(ReaderStream::with_capacity(reader, 256 * 1024)) as BlobStream)
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
// S3 DeleteObject is already idempotent (returns 204 even if not found)
self.client
.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::internal_error(
"S3",
format!("Failed to delete blob {}: {}", hash, e),
)
})?;
Ok(())
})
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
match self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
{
Ok(_) => Ok(true),
Err(e) => {
// Check if it's a 404 (not found) vs an actual error
let service_err = e.into_service_error();
if service_err.is_not_found() {
Ok(false)
} else {
Err(DomainError::internal_error(
"S3",
format!("Failed to check blob {}: {}", hash, service_err),
))
}
}
}
})
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let key = Self::object_key(&hash);
let output = self
.client
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"S3",
format!("Failed to stat blob {}: {}", hash, e),
)
})?;
Ok(output.content_length().unwrap_or(0) as u64)
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
Box::pin(async move {
match self.client.head_bucket().bucket(&self.bucket).send().await {
Ok(_) => Ok(StorageHealthStatus {
connected: true,
backend_type: "s3".to_string(),
message: format!("S3 bucket '{}' is accessible", self.bucket),
available_bytes: None,
}),
Err(e) => Ok(StorageHealthStatus {
connected: false,
backend_type: "s3".to_string(),
message: format!("S3 bucket '{}' is not accessible: {}", self.bucket, e),
available_bytes: None,
}),
}
})
}
fn backend_type(&self) -> &'static str {
"s3"
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None // Remote backend — no local path
}
}