Files
Oxicloud/src/infrastructure/services/chunked_upload_service.rs
T

688 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Chunked Upload Service - TUS-like Protocol for Large File Uploads
//!
//! Enables parallel chunk uploads for files >10MB with:
//! - Resumable uploads (persist progress)
//! - Parallel chunk transfers (up to 6 concurrent)
//! - Automatic reassembly
//! - Expiration cleanup (24h)
//!
//! Protocol:
//! 1. POST /api/uploads → Create upload session, get upload_id
//! 2. PATCH /api/uploads/:id → Upload chunks (parallel OK)
//! 3. HEAD /api/uploads/:id → Check progress
//! 4. POST /api/uploads/:id/complete → Finalize and assemble
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::{AsyncWriteExt, BufWriter};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::application::ports::chunked_upload_ports::{
ChunkUploadResponseDto, ChunkedUploadPort, CreateUploadResponseDto, UploadStatusResponseDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Minimum file size to use chunked upload (10MB)
pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024;
/// Default chunk size (5MB) - optimized for parallel transfers
pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024;
/// Maximum concurrent chunks per upload
pub const MAX_PARALLEL_CHUNKS: usize = 6;
/// Upload session expiration time
const SESSION_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours
/// Chunk status
#[derive(Debug, Clone, PartialEq)]
pub enum ChunkStatus {
Pending,
Uploading,
Complete,
Failed(String),
}
/// Individual chunk metadata
#[derive(Debug, Clone)]
pub struct ChunkInfo {
pub index: usize,
pub offset: u64,
pub size: usize,
pub status: ChunkStatus,
pub checksum: Option<String>,
}
/// Upload session state
#[derive(Debug, Clone)]
pub struct UploadSession {
pub id: String,
pub filename: String,
pub folder_id: Option<String>,
pub content_type: String,
pub total_size: u64,
pub chunk_size: usize,
pub chunks: Vec<ChunkInfo>,
pub created_at: Instant,
pub last_activity: Instant,
pub temp_dir: PathBuf,
pub bytes_received: u64,
}
impl UploadSession {
/// Calculate number of chunks needed
pub fn calculate_chunk_count(total_size: u64, chunk_size: usize) -> usize {
(total_size as usize).div_ceil(chunk_size).max(1)
}
/// Get upload progress (0.0 - 1.0)
pub fn progress(&self) -> f64 {
if self.total_size == 0 {
return 1.0;
}
self.bytes_received as f64 / self.total_size as f64
}
/// Check if all chunks are complete
pub fn is_complete(&self) -> bool {
self.chunks
.iter()
.all(|c| c.status == ChunkStatus::Complete)
}
/// Get pending chunk indices
pub fn pending_chunks(&self) -> Vec<usize> {
self.chunks
.iter()
.enumerate()
.filter(|(_, c)| c.status == ChunkStatus::Pending)
.map(|(i, _)| i)
.collect()
}
/// Check if session has expired
pub fn is_expired(&self) -> bool {
self.last_activity.elapsed() > SESSION_EXPIRATION
}
}
/// Response for upload session creation
#[derive(Debug, Clone, serde::Serialize)]
pub struct CreateUploadResponse {
pub upload_id: String,
pub chunk_size: usize,
pub total_chunks: usize,
pub expires_at: u64,
}
/// Response for chunk upload
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChunkUploadResponse {
pub chunk_index: usize,
pub bytes_received: u64,
pub progress: f64,
pub is_complete: bool,
}
/// Response for upload status
#[derive(Debug, Clone, serde::Serialize)]
pub struct UploadStatusResponse {
pub upload_id: String,
pub filename: String,
pub total_size: u64,
pub bytes_received: u64,
pub progress: f64,
pub total_chunks: usize,
pub completed_chunks: usize,
pub pending_chunks: Vec<usize>,
pub is_complete: bool,
}
/// Chunked Upload Service
pub struct ChunkedUploadService {
sessions: Arc<RwLock<HashMap<String, UploadSession>>>,
temp_base_dir: PathBuf,
}
impl ChunkedUploadService {
/// Create new service with temp directory for chunks
pub fn new(temp_base_dir: PathBuf) -> Self {
let service = Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
temp_base_dir,
};
// Start cleanup task
let sessions_clone = service.sessions.clone();
let temp_dir_clone = service.temp_base_dir.clone();
tokio::spawn(async move {
Self::cleanup_loop(sessions_clone, temp_dir_clone).await;
});
service
}
/// Background task to clean expired sessions
async fn cleanup_loop(
sessions: Arc<RwLock<HashMap<String, UploadSession>>>,
temp_base_dir: PathBuf,
) {
let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour
loop {
interval.tick().await;
let expired: Vec<String> = {
let sessions = sessions.read().await;
sessions
.iter()
.filter(|(_, s)| s.is_expired())
.map(|(id, _)| id.clone())
.collect()
};
for id in expired {
let mut sessions = sessions.write().await;
if let Some(session) = sessions.remove(&id) {
// Clean up temp files
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
tracing::warn!("Failed to cleanup expired upload {}: {}", id, e);
} else {
tracing::info!("🧹 Cleaned expired upload session: {}", id);
}
}
}
// Also clean orphaned temp directories
if let Ok(mut entries) = fs::read_dir(&temp_base_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Check if this directory belongs to an active session
let sessions = sessions.read().await;
if !sessions.contains_key(dir_name) {
// Check if directory is old (>24h)
if let Ok(metadata) = fs::metadata(&path).await
&& let Ok(modified) = metadata.modified()
&& modified.elapsed().unwrap_or_default() > SESSION_EXPIRATION
{
let _ = fs::remove_dir_all(&path).await;
tracing::info!("🧹 Cleaned orphaned upload dir: {:?}", path);
}
}
}
}
}
}
}
/// Create a new upload session
pub async fn create_session(
&self,
filename: String,
folder_id: Option<String>,
content_type: String,
total_size: u64,
chunk_size: Option<usize>,
) -> Result<CreateUploadResponse, String> {
let upload_id = Uuid::new_v4().to_string();
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
let chunk_count = UploadSession::calculate_chunk_count(total_size, chunk_size);
// Create temp directory for chunks
let temp_dir = self.temp_base_dir.join(&upload_id);
fs::create_dir_all(&temp_dir)
.await
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
// Initialize chunk metadata
let mut chunks = Vec::with_capacity(chunk_count);
let mut offset: u64 = 0;
for i in 0..chunk_count {
let size = if i == chunk_count - 1 {
// Last chunk may be smaller
(total_size - offset) as usize
} else {
chunk_size
};
chunks.push(ChunkInfo {
index: i,
offset,
size,
status: ChunkStatus::Pending,
checksum: None,
});
offset += size as u64;
}
let now = Instant::now();
let session = UploadSession {
id: upload_id.clone(),
filename,
folder_id,
content_type,
total_size,
chunk_size,
chunks,
created_at: now,
last_activity: now,
temp_dir,
bytes_received: 0,
};
let expires_at = SESSION_EXPIRATION.as_secs();
{
let mut sessions = self.sessions.write().await;
sessions.insert(upload_id.clone(), session);
}
tracing::info!(
"📤 Created chunked upload session: {} ({} chunks, {} bytes each)",
upload_id,
chunk_count,
chunk_size
);
Ok(CreateUploadResponse {
upload_id,
chunk_size,
total_chunks: chunk_count,
expires_at,
})
}
/// Upload a single chunk
pub async fn upload_chunk(
&self,
upload_id: &str,
chunk_index: usize,
data: bytes::Bytes,
checksum: Option<String>,
) -> Result<ChunkUploadResponse, String> {
// Validate session exists and chunk index is valid
let (chunk_path, expected_size) = {
let sessions = self.sessions.read().await;
let session = sessions
.get(upload_id)
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
if chunk_index >= session.chunks.len() {
return Err(format!(
"Invalid chunk index: {} (max: {})",
chunk_index,
session.chunks.len() - 1
));
}
let chunk = &session.chunks[chunk_index];
if chunk.status == ChunkStatus::Complete {
return Err(format!("Chunk {} already uploaded", chunk_index));
}
(
session.temp_dir.join(format!("chunk_{:06}", chunk_index)),
chunk.size,
)
};
// Validate chunk size
if data.len() != expected_size {
return Err(format!(
"Invalid chunk size: expected {} bytes, got {} bytes",
expected_size,
data.len()
));
}
// Verify checksum if provided
if let Some(ref expected_checksum) = checksum {
let actual_checksum = format!("{:x}", md5::compute(&data));
if &actual_checksum != expected_checksum {
return Err(format!(
"Checksum mismatch: expected {}, got {}",
expected_checksum, actual_checksum
));
}
}
// Write chunk to temp file
let mut file = File::create(&chunk_path)
.await
.map_err(|e| format!("Failed to create chunk file: {}", e))?;
file.write_all(&data)
.await
.map_err(|e| format!("Failed to write chunk: {}", e))?;
// Chunks are temporary — no need for fsync. The final assembled
// file is synced once after all chunks are merged.
// Update session state
let (bytes_received, progress, is_complete) = {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(upload_id)
.ok_or_else(|| "Session disappeared".to_string())?;
session.chunks[chunk_index].status = ChunkStatus::Complete;
session.chunks[chunk_index].checksum = checksum;
session.bytes_received += data.len() as u64;
session.last_activity = Instant::now();
(
session.bytes_received,
session.progress(),
session.is_complete(),
)
};
tracing::debug!(
"📦 Chunk {}/{} uploaded for {} ({:.1}% complete)",
chunk_index + 1,
expected_size,
upload_id,
progress * 100.0
);
Ok(ChunkUploadResponse {
chunk_index,
bytes_received,
progress,
is_complete,
})
}
/// Get upload status
pub async fn get_status(&self, upload_id: &str) -> Result<UploadStatusResponse, String> {
let sessions = self.sessions.read().await;
let session = sessions
.get(upload_id)
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
let completed_chunks = session
.chunks
.iter()
.filter(|c| c.status == ChunkStatus::Complete)
.count();
Ok(UploadStatusResponse {
upload_id: session.id.clone(),
filename: session.filename.clone(),
total_size: session.total_size,
bytes_received: session.bytes_received,
progress: session.progress(),
total_chunks: session.chunks.len(),
completed_chunks,
pending_chunks: session.pending_chunks(),
is_complete: session.is_complete(),
})
}
/// Assemble chunks into final file and return the path + pre-computed SHA-256 hash.
///
/// **Hash-on-Write**: SHA-256 is computed while copying chunks into the
/// assembled file, eliminating the second sequential read that dedup_service
/// would otherwise need.
///
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size, sha256_hash)`.
pub async fn complete_upload(
&self,
upload_id: &str,
) -> Result<(PathBuf, String, Option<String>, String, u64, String), String> {
// Get session and validate completion
let session = {
let sessions = self.sessions.read().await;
let session = sessions
.get(upload_id)
.ok_or_else(|| format!("Upload session not found: {}", upload_id))?;
if !session.is_complete() {
let pending = session.pending_chunks();
return Err(format!(
"Upload not complete. Missing chunks: {:?}",
pending
));
}
session.clone()
};
// Assemble file with hash-on-write
let assembled_path = session.temp_dir.join("assembled");
let raw_output = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&assembled_path)
.await
.map_err(|e| format!("Failed to create assembled file: {}", e))?;
// Pre-allocate assembled file to reduce fragmentation
let _ = raw_output.set_len(session.total_size).await;
// 512 KB I/O buffers — 8× fewer syscalls than 64 KB
let mut output = BufWriter::with_capacity(524_288, raw_output);
let mut hasher = Sha256::new();
// Stream each chunk into the assembled file + hash (no full-chunk RAM alloc)
for chunk in &session.chunks {
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index));
let mut chunk_file = File::open(&chunk_path)
.await
.map_err(|e| format!("Failed to open chunk {}: {}", chunk.index, e))?;
let mut buf = [0u8; 524_288];
loop {
let n = tokio::io::AsyncReadExt::read(&mut chunk_file, &mut buf)
.await
.map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
output.write_all(&buf[..n]).await.map_err(|e| {
format!(
"Failed to write chunk {} to assembled file: {}",
chunk.index, e
)
})?;
}
}
tokio::io::AsyncWriteExt::flush(&mut output)
.await
.map_err(|e| format!("Failed to flush assembled file: {}", e))?;
// No sync_all() — durability guaranteed by PG WAL on tx commit.
// The file is immediately renamed to .blobs/ (atomic move).
let hash = hex::encode(hasher.finalize());
// Clean up chunk files (keep assembled)
for chunk in &session.chunks {
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index));
let _ = fs::remove_file(&chunk_path).await;
}
tracing::info!(
"✅ Assembled chunked upload: {} ({} bytes from {} chunks)",
session.filename,
session.total_size,
session.chunks.len()
);
Ok((
assembled_path,
session.filename.clone(),
session.folder_id.clone(),
session.content_type.clone(),
session.total_size,
hash,
))
}
/// Finalize upload: move assembled file to final location and cleanup session
pub async fn finalize_upload(&self, upload_id: &str) -> Result<(), String> {
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.remove(upload_id) {
// Clean up entire temp directory
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
tracing::warn!("Failed to cleanup upload {}: {}", upload_id, e);
}
}
Ok(())
}
/// Cancel an upload and cleanup
pub async fn cancel_upload(&self, upload_id: &str) -> Result<(), String> {
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.remove(upload_id) {
if let Err(e) = fs::remove_dir_all(&session.temp_dir).await {
tracing::warn!("Failed to cleanup cancelled upload {}: {}", upload_id, e);
}
tracing::info!("❌ Cancelled chunked upload: {}", upload_id);
}
Ok(())
}
/// Check if file size qualifies for chunked upload
pub fn should_use_chunked(size: u64) -> bool {
size as usize >= CHUNKED_UPLOAD_THRESHOLD
}
/// Get active session count (for monitoring)
pub async fn active_sessions(&self) -> usize {
self.sessions.read().await.len()
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
#[async_trait]
impl ChunkedUploadPort for ChunkedUploadService {
async fn create_session(
&self,
filename: String,
folder_id: Option<String>,
content_type: String,
total_size: u64,
chunk_size: Option<usize>,
) -> Result<CreateUploadResponseDto, DomainError> {
let resp = self
.create_session(filename, folder_id, content_type, total_size, chunk_size)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?;
Ok(CreateUploadResponseDto {
upload_id: resp.upload_id,
chunk_size: resp.chunk_size,
total_chunks: resp.total_chunks,
expires_at: resp.expires_at,
})
}
async fn upload_chunk(
&self,
upload_id: &str,
chunk_index: usize,
data: bytes::Bytes,
checksum: Option<String>,
) -> Result<ChunkUploadResponseDto, DomainError> {
let resp = self
.upload_chunk(upload_id, chunk_index, data, checksum)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?;
Ok(ChunkUploadResponseDto {
chunk_index: resp.chunk_index,
bytes_received: resp.bytes_received,
progress: resp.progress,
is_complete: resp.is_complete,
})
}
async fn get_status(&self, upload_id: &str) -> Result<UploadStatusResponseDto, DomainError> {
let resp = self
.get_status(upload_id)
.await
.map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?;
Ok(UploadStatusResponseDto {
upload_id: resp.upload_id,
filename: resp.filename,
total_size: resp.total_size,
bytes_received: resp.bytes_received,
progress: resp.progress,
total_chunks: resp.total_chunks,
completed_chunks: resp.completed_chunks,
pending_chunks: resp.pending_chunks,
is_complete: resp.is_complete,
})
}
async fn complete_upload(
&self,
upload_id: &str,
) -> Result<(PathBuf, String, Option<String>, String, u64, String), DomainError> {
self.complete_upload(upload_id)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
}
async fn finalize_upload(&self, upload_id: &str) -> Result<(), DomainError> {
self.finalize_upload(upload_id)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
}
async fn cancel_upload(&self, upload_id: &str) -> Result<(), DomainError> {
self.cancel_upload(upload_id)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
}
fn should_use_chunked(&self, size: u64) -> bool {
ChunkedUploadService::should_use_chunked(size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_count_calculation() {
// 10MB file with 5MB chunks = 2 chunks
assert_eq!(
UploadSession::calculate_chunk_count(10 * 1024 * 1024, 5 * 1024 * 1024),
2
);
// 11MB file with 5MB chunks = 3 chunks
assert_eq!(
UploadSession::calculate_chunk_count(11 * 1024 * 1024, 5 * 1024 * 1024),
3
);
// 1 byte file = 1 chunk
assert_eq!(UploadSession::calculate_chunk_count(1, 5 * 1024 * 1024), 1);
// 0 byte file = 1 chunk
assert_eq!(UploadSession::calculate_chunk_count(0, 5 * 1024 * 1024), 1);
}
#[test]
fn test_should_use_chunked() {
assert!(!ChunkedUploadService::should_use_chunked(9 * 1024 * 1024));
assert!(ChunkedUploadService::should_use_chunked(10 * 1024 * 1024));
assert!(ChunkedUploadService::should_use_chunked(100 * 1024 * 1024));
}
}