refactoring hexagonal and clean architecture

This commit is contained in:
Diocrafts
2026-02-08 13:40:23 +01:00
parent 3e4fb67c19
commit a82faa5eaf
101 changed files with 7433 additions and 9721 deletions
+32 -15
View File
@@ -89,9 +89,11 @@ impl BufferPool {
)
}
/// Obtiene un buffer del pool o crea uno nuevo si es necesario
/// Obtiene un buffer del pool o crea uno nuevo si es necesario.
/// This version takes an Arc<Self> to ensure the BorrowedBuffer keeps a proper
/// reference to the shared pool (not a clone).
#[allow(unused_variables)]
pub async fn get_buffer(&self) -> BorrowedBuffer {
pub async fn get_buffer(self: &Arc<Self>) -> BorrowedBuffer {
// Incrementar contador de gets
{
let mut stats = self.stats.lock().await;
@@ -99,27 +101,31 @@ impl BufferPool {
}
// Control de concurrencia
// Usando el mecanismo RAII de Rust para gestión automática
// de recursos al finalizar la función
let _ = match self.limit.try_acquire() {
Ok(_permit) => _permit, // _ prefix para indicar que es intencional
// Acquire a semaphore permit. If none available, wait.
// We forget() the permit so it doesn't auto-release on drop.
// Instead, the permit is manually released in return_buffer/Drop via add_permits(1).
match self.limit.try_acquire() {
Ok(permit) => permit.forget(),
Err(_) => {
// No hay permisos disponibles, esperamos
let mut stats = self.stats.lock().await;
stats.waits += 1;
stats.max_buffers_reached += 1;
drop(stats);
{
let mut stats = self.stats.lock().await;
stats.waits += 1;
stats.max_buffers_reached += 1;
}
debug!("Buffer pool: waiting for available buffer");
let _permit = self.limit.acquire().await.expect("Semaphore should not be closed");
let permit = self.limit.acquire().await.expect("Semaphore should not be closed");
debug!("Buffer pool: acquired buffer after waiting");
_permit
permit.forget();
}
};
// Intentar obtener un buffer existente del pool
let mut pool_locked = self.pool.lock().await;
let pool_arc = Arc::clone(self);
if let Some(mut pooled_buffer) = pool_locked.pop_front() {
// Verificar si el buffer ha expirado
if pooled_buffer.last_used.elapsed() > self.buffer_ttl {
@@ -137,7 +143,7 @@ impl BufferPool {
BorrowedBuffer {
buffer: vec![0; self.buffer_size],
used_size: 0,
pool: Arc::new(self.clone()),
pool: pool_arc,
return_to_pool: true,
}
} else {
@@ -155,7 +161,7 @@ impl BufferPool {
BorrowedBuffer {
buffer: pooled_buffer.buffer,
used_size: 0,
pool: Arc::new(self.clone()),
pool: pool_arc,
return_to_pool: true,
}
}
@@ -173,7 +179,7 @@ impl BufferPool {
BorrowedBuffer {
buffer: vec![0; self.buffer_size],
used_size: 0,
pool: Arc::new(self.clone()),
pool: pool_arc,
return_to_pool: true,
}
}
@@ -185,6 +191,8 @@ impl BufferPool {
if buffer.capacity() != self.buffer_size {
debug!("Buffer pool: discarding buffer of wrong size: {} (expected {})",
buffer.capacity(), self.buffer_size);
// Release the semaphore permit even if we discard the buffer
self.limit.add_permits(1);
return;
}
@@ -202,6 +210,11 @@ impl BufferPool {
// Actualizar estadísticas
let mut stats = self.stats.lock().await;
stats.returns += 1;
// Release the semaphore permit so another caller can acquire a buffer
drop(pool_locked);
drop(stats);
self.limit.add_permits(1);
}
/// Limpia buffers expirados del pool
@@ -331,9 +344,13 @@ impl Drop for BorrowedBuffer {
let pool = self.pool.clone();
// Spawn del return para que el drop no bloquee
// return_buffer will release the semaphore permit
tokio::spawn(async move {
pool.return_buffer(buffer).await;
});
} else {
// Buffer not returned to pool, but we still need to release the semaphore permit
self.pool.limit.add_permits(1);
}
}
}
@@ -1,200 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time;
use futures::future::BoxFuture;
use tokio::sync::RwLock;
/// Representación de metadatos en caché
#[derive(Debug, Clone)]
pub struct CachedMetadata {
/// Si el archivo o directorio existe
pub exists: bool,
/// Tamaño en bytes (para archivos)
pub size: Option<u64>,
/// Timestamp de creación
pub created_at: Option<u64>,
/// Timestamp de modificación
pub modified_at: Option<u64>,
/// Tiempo de expiración de la caché
expires_at: Instant,
}
/// Estructura para gestionar la caché de metadatos de archivos y directorios
pub struct StorageCacheManager {
/// Caché de existencia y metadatos
cache: RwLock<HashMap<PathBuf, CachedMetadata>>,
/// TTL para entradas de archivos (milisegundos)
file_ttl_ms: u64,
/// TTL para entradas de directorios (milisegundos)
dir_ttl_ms: u64,
/// Tamaño máximo de caché
max_entries: usize,
}
impl StorageCacheManager {
/// Crea una nueva instancia del gestor de caché
pub fn new(file_ttl_ms: u64, dir_ttl_ms: u64, max_entries: usize) -> Self {
Self {
cache: RwLock::new(HashMap::with_capacity(max_entries)),
file_ttl_ms,
dir_ttl_ms,
max_entries,
}
}
/// Crea una instancia por defecto del gestor de caché
pub fn default() -> Self {
Self::new(
60_000, // 1 minuto para archivos
300_000, // 5 minutos para directorios
10_000, // máximo 10,000 entradas
)
}
/// Verifica si un archivo o directorio existe en caché
pub async fn check_exists(&self, path: &PathBuf, _is_dir: bool) -> Result<bool, ()> {
// Intentar obtener de la caché
if let Some(metadata) = self.get_cached_metadata(path).await {
return Ok(metadata.exists);
}
// No está en caché
Err(())
}
/// Obtiene los metadatos de un path desde la caché
async fn get_cached_metadata(&self, path: &PathBuf) -> Option<CachedMetadata> {
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(path) {
// Verificar si la entrada expiró
if Instant::now() < metadata.expires_at {
return Some(metadata.clone());
}
}
None
}
/// Actualiza la caché con los metadatos de un path
pub async fn update_cache(&self, path: &PathBuf, exists: bool, size: Option<u64>,
created_at: Option<u64>, modified_at: Option<u64>, is_dir: bool) {
let mut cache = self.cache.write().await;
// Si la caché está llena, eliminar entradas aleatorias antes de agregar
if cache.len() >= self.max_entries {
self.evict_entries(&mut cache, 100).await;
}
// Determinar TTL basado en si es archivo o directorio
let ttl = if is_dir {
Duration::from_millis(self.dir_ttl_ms)
} else {
Duration::from_millis(self.file_ttl_ms)
};
// Crear metadatos y agregar a la caché
let metadata = CachedMetadata {
exists,
size,
created_at,
modified_at,
expires_at: Instant::now() + ttl,
};
cache.insert(path.clone(), metadata);
}
/// Elimina entradas aleatorias de la caché cuando está llena
async fn evict_entries(&self, cache: &mut HashMap<PathBuf, CachedMetadata>, count: usize) {
// Obtener las entradas más antiguas para eliminar
let mut entries: Vec<_> = cache.keys().cloned().collect();
// Limitar el número de entradas a eliminar
let to_remove = count.min(entries.len() / 10);
if to_remove == 0 {
return;
}
// Eliminar las primeras entradas (implementación simple)
entries.truncate(to_remove);
for path in entries {
cache.remove(&path);
}
}
/// Inicia una tarea de limpieza periódica
pub fn start_cleanup_task(cache_manager: Arc<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
let interval = Duration::from_secs(60); // Ejecutar cada minuto
loop {
time::sleep(interval).await;
// Limpiar entradas expiradas
let now = Instant::now();
let mut cache = cache_manager.cache.write().await;
// Encontrar entradas expiradas
let expired: Vec<_> = cache
.iter()
.filter(|(_, metadata)| now > metadata.expires_at)
.map(|(path, _)| path.clone())
.collect();
// Eliminar entradas expiradas
for path in expired {
cache.remove(&path);
}
// Registrar estadísticas
let cache_size = cache.len();
drop(cache);
tracing::debug!("Cache cleanup completed. Entries remaining: {}", cache_size);
}
})
}
/// Invalida una entrada específica de la caché
pub async fn invalidate(&self, path: &PathBuf) {
let mut cache = self.cache.write().await;
cache.remove(path);
}
/// Invalida todas las entradas de la caché relacionadas con una carpeta
pub async fn invalidate_folder(&self, folder_path: &PathBuf) {
let mut cache = self.cache.write().await;
// Eliminar entradas que sean descendientes de la carpeta
let folder_str = folder_path.to_string_lossy().to_string();
// Encontrar entradas a eliminar
let to_remove: Vec<_> = cache
.keys()
.filter_map(|path| {
let path_str = path.to_string_lossy().to_string();
if path_str.starts_with(&folder_str) {
Some(path.clone())
} else {
None
}
})
.collect();
// Eliminar las entradas
for path in to_remove {
cache.remove(&path);
}
}
/// Obtiene el número actual de entradas en la caché
pub async fn cache_size(&self) -> usize {
let cache = self.cache.read().await;
cache.len()
}
}
@@ -20,6 +20,15 @@ use tokio::fs::{self, File, OpenOptions};
use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock;
use uuid::Uuid;
use async_trait::async_trait;
use crate::application::ports::chunked_upload_ports::{
ChunkedUploadPort,
CreateUploadResponseDto,
ChunkUploadResponseDto,
UploadStatusResponseDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Minimum file size to use chunked upload (10MB)
pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024;
@@ -507,6 +516,93 @@ impl ChunkedUploadService {
}
}
// ─── 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), 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::*;
@@ -9,6 +9,11 @@ use flate2::Compression;
use flate2::read::GzEncoder as GzEncoderRead;
use flate2::bufread::GzDecoder;
use crate::application::ports::compression_ports::{
CompressionPort,
CompressionLevel as PortCompressionLevel,
};
use crate::domain::errors::DomainError;
use crate::infrastructure::services::buffer_pool::BufferPool;
/// Nivel de compresión para ficheros
@@ -254,7 +259,7 @@ impl CompressionService for GzipCompressionService {
}
// Compress collected data
match self.compress_data(&data, compression_level).await {
match CompressionService::compress_data(self, &data, compression_level).await {
Ok(compressed) => {
// Return compressed data as a single chunk
yield Ok(Bytes::from(compressed));
@@ -293,7 +298,7 @@ impl CompressionService for GzipCompressionService {
}
// Decompress collected data
match self.decompress_data(&compressed_data).await {
match CompressionService::decompress_data(self, &compressed_data).await {
Ok(decompressed) => {
// Return decompressed data as a single chunk
yield Ok(Bytes::from(decompressed));
@@ -345,6 +350,35 @@ impl CompressionService for GzipCompressionService {
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
/// Convert application-layer CompressionLevel to infrastructure CompressionLevel.
impl From<PortCompressionLevel> for CompressionLevel {
fn from(level: PortCompressionLevel) -> Self {
match level {
PortCompressionLevel::None => CompressionLevel::None,
PortCompressionLevel::Fast => CompressionLevel::Fast,
PortCompressionLevel::Default => CompressionLevel::Default,
PortCompressionLevel::Best => CompressionLevel::Best,
}
}
}
#[async_trait]
impl CompressionPort for GzipCompressionService {
async fn compress_data(&self, data: &[u8], level: PortCompressionLevel) -> Result<Vec<u8>, DomainError> {
CompressionService::compress_data(self, data, level.into()).await.map_err(DomainError::from)
}
async fn decompress_data(&self, compressed_data: &[u8]) -> Result<Vec<u8>, DomainError> {
CompressionService::decompress_data(self, compressed_data).await.map_err(DomainError::from)
}
fn should_compress(&self, mime_type: &str, size: u64) -> bool {
CompressionService::should_compress(self, mime_type, size)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -359,13 +393,13 @@ mod tests {
let data = "Hello, world! ".repeat(1000).into_bytes();
// Comprimir
let compressed = service.compress_data(&data, CompressionLevel::Default).await.unwrap();
let compressed = CompressionService::compress_data(&service, &data, CompressionLevel::Default).await.unwrap();
// Verificar que la compresión reduce el tamaño
assert!(compressed.len() < data.len());
// Descomprimir
let decompressed = service.decompress_data(&compressed).await.unwrap();
let decompressed = CompressionService::decompress_data(&service, &compressed).await.unwrap();
// Verificar que los datos originales se recuperan correctamente
assert_eq!(decompressed, data);
@@ -396,7 +430,7 @@ mod tests {
}).await.unwrap();
// Descomprimir los datos
let decompressed = service.decompress_data(&compressed_bytes).await.unwrap();
let decompressed = CompressionService::decompress_data(&service, &compressed_bytes).await.unwrap();
// Verificar resultado
let expected = "Hello, world! This is a test of streaming compression.";
@@ -408,16 +442,16 @@ mod tests {
let service = GzipCompressionService::new();
// Casos que no deberían comprimirse
assert!(!service.should_compress("image/jpeg", 100 * 1024));
assert!(!service.should_compress("video/mp4", 10 * 1024 * 1024));
assert!(!service.should_compress("application/zip", 5 * 1024 * 1024));
assert!(!CompressionService::should_compress(&service, "image/jpeg", 100 * 1024));
assert!(!CompressionService::should_compress(&service, "video/mp4", 10 * 1024 * 1024));
assert!(!CompressionService::should_compress(&service, "application/zip", 5 * 1024 * 1024));
// Casos que sí deberían comprimirse
assert!(service.should_compress("text/html", 100 * 1024));
assert!(service.should_compress("application/json", 200 * 1024));
assert!(service.should_compress("text/plain", 1024 * 1024));
assert!(CompressionService::should_compress(&service, "text/html", 100 * 1024));
assert!(CompressionService::should_compress(&service, "application/json", 200 * 1024));
assert!(CompressionService::should_compress(&service, "text/plain", 1024 * 1024));
// Archivos pequeños no deberían comprimirse independientemente del tipo
assert!(!service.should_compress("text/html", 10 * 1024));
assert!(!CompressionService::should_compress(&service, "text/html", 10 * 1024));
}
}
+128 -3
View File
@@ -5,7 +5,7 @@
//! to the same physical blob.
//!
//! Architecture:
//! ```
//! ```text
//! ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
//! │ User Files │────▶│ Dedup Index │────▶│ Blob Store │
//! │ (references) │ │ (hash→metadata) │ │ (actual data) │
@@ -26,6 +26,15 @@ use tokio::sync::RwLock;
use sha2::{Sha256, Digest};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use async_trait::async_trait;
use crate::application::ports::dedup_ports::{
DedupPort,
BlobMetadataDto,
DedupResultDto,
DedupStatsDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Chunk size for streaming hash calculation (256KB)
const HASH_CHUNK_SIZE: usize = 256 * 1024;
@@ -664,6 +673,120 @@ impl DedupService {
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
/// Convert infra DedupResult to port DedupResultDto.
impl From<DedupResult> for DedupResultDto {
fn from(result: DedupResult) -> Self {
match result {
DedupResult::NewBlob { hash, size, blob_path } => {
DedupResultDto::NewBlob { hash, size, blob_path }
}
DedupResult::ExistingBlob { hash, size, blob_path, saved_bytes } => {
DedupResultDto::ExistingBlob { hash, size, blob_path, saved_bytes }
}
}
}
}
/// Convert infra BlobMetadata to port BlobMetadataDto.
impl From<BlobMetadata> for BlobMetadataDto {
fn from(m: BlobMetadata) -> Self {
BlobMetadataDto {
hash: m.hash,
size: m.size,
ref_count: m.ref_count,
content_type: m.content_type,
}
}
}
/// Convert infra DedupStats to port DedupStatsDto.
impl From<DedupStats> for DedupStatsDto {
fn from(s: DedupStats) -> Self {
DedupStatsDto {
total_blobs: s.total_blobs,
total_bytes_stored: s.total_bytes_stored,
total_bytes_referenced: s.total_bytes_referenced,
bytes_saved: s.bytes_saved,
dedup_hits: s.dedup_hits,
dedup_ratio: s.dedup_ratio,
}
}
}
#[async_trait]
impl DedupPort for DedupService {
async fn store_bytes(
&self,
content: &[u8],
content_type: Option<String>,
) -> Result<DedupResultDto, DomainError> {
self.store_bytes(content, content_type).await
.map(Into::into)
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e))
}
async fn store_from_file(
&self,
source_path: &Path,
content_type: Option<String>,
) -> Result<DedupResultDto, DomainError> {
self.store_from_file(source_path, content_type).await
.map(Into::into)
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e))
}
async fn blob_exists(&self, hash: &str) -> bool {
self.blob_exists(hash).await
}
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto> {
self.get_blob_metadata(hash).await.map(Into::into)
}
async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError> {
self.read_blob(hash).await
.map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e))
}
async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError> {
self.read_blob_bytes(hash).await
.map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e))
}
async fn add_reference(&self, hash: &str) -> Result<(), DomainError> {
self.add_reference(hash).await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e))
}
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError> {
self.remove_reference(hash).await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e))
}
fn hash_bytes(&self, content: &[u8]) -> String {
DedupService::hash_bytes(content)
}
async fn hash_file(&self, path: &Path) -> Result<String, DomainError> {
DedupService::hash_file(path).await.map_err(DomainError::from)
}
async fn get_stats(&self) -> DedupStatsDto {
self.get_stats().await.into()
}
async fn flush(&self) -> Result<(), DomainError> {
self.flush().await.map_err(DomainError::from)
}
async fn verify_integrity(&self) -> Result<Vec<String>, DomainError> {
self.verify_integrity().await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -675,7 +798,8 @@ mod tests {
let service = DedupService::new(temp_dir.path());
service.initialize().await.unwrap();
let content = b"Hello, World! This is test content.";
// Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in
let content = &b"Hello, World! This is test content for dedup. ".repeat(100);
// First store
let result1 = service.store_bytes(content, None).await.unwrap();
@@ -698,7 +822,8 @@ mod tests {
let service = DedupService::new(temp_dir.path());
service.initialize().await.unwrap();
let content = b"Test content for reference counting";
// Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in
let content = &b"Test content for reference counting. ".repeat(120);
// Store twice
let result1 = service.store_bytes(content, None).await.unwrap();
@@ -213,6 +213,34 @@ pub struct CacheStats {
/// Thread-safe wrapper for sharing across handlers
pub type SharedFileContentCache = Arc<FileContentCache>;
// ─── ContentCachePort implementation ─────────────────────────
use async_trait::async_trait;
use crate::application::ports::cache_ports::ContentCachePort;
#[async_trait]
impl ContentCachePort for FileContentCache {
fn should_cache(&self, size: usize) -> bool {
FileContentCache::should_cache(self, size)
}
async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> {
FileContentCache::get(self, file_id).await
}
async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) {
FileContentCache::put(self, file_id, content, etag, content_type).await
}
async fn invalidate(&self, file_id: &str) {
FileContentCache::invalidate(self, file_id).await
}
async fn clear(&self) {
FileContentCache::clear(self).await
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -598,6 +598,56 @@ impl FileMetadataCache {
}
}
// ─── MetadataCachePort implementation ────────────────────────
use async_trait::async_trait;
use crate::application::ports::cache_ports::{MetadataCachePort, CachedMetadataDto};
use crate::common::errors::DomainError;
#[async_trait]
impl MetadataCachePort for FileMetadataCache {
async fn get_metadata(&self, path: &Path) -> Option<CachedMetadataDto> {
// Delegate to the existing rich get_metadata, then project into the DTO.
let fm = FileMetadataCache::get_metadata(self, path).await?;
Some(CachedMetadataDto {
path: fm.path,
exists: fm.exists,
is_file: fm.entry_type == CacheEntryType::File,
size: fm.size,
mime_type: fm.mime_type,
created_at: fm.created_at,
modified_at: fm.modified_at,
})
}
async fn is_file(&self, path: &Path) -> Option<bool> {
FileMetadataCache::is_file(self, path).await
}
async fn refresh_metadata(&self, path: &Path) -> Result<CachedMetadataDto, DomainError> {
let fm = FileMetadataCache::refresh_metadata(self, path)
.await
.map_err(|e| DomainError::internal_error("MetadataCache", e.to_string()))?;
Ok(CachedMetadataDto {
path: fm.path,
exists: fm.exists,
is_file: fm.entry_type == CacheEntryType::File,
size: fm.size,
mime_type: fm.mime_type,
created_at: fm.created_at,
modified_at: fm.modified_at,
})
}
async fn invalidate(&self, path: &Path) {
FileMetadataCache::invalidate(self, path).await
}
async fn invalidate_directory(&self, dir_path: &Path) {
FileMetadataCache::invalidate_directory(self, dir_path).await
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -648,10 +698,12 @@ mod tests {
async fn test_directory_operations() {
// Crear estructura de directorios para pruebas
let temp_dir = tempdir().unwrap();
let sub_dir = temp_dir.path().join("subdir");
// Canonicalize to handle macOS /var -> /private/var symlinks
let base_path = temp_dir.path().canonicalize().unwrap();
let sub_dir = base_path.join("subdir");
fs::create_dir(&sub_dir).await.unwrap();
let file1 = temp_dir.path().join("file1.txt");
let file1 = base_path.join("file1.txt");
let file2 = sub_dir.join("file2.txt");
File::create(&file1).await.unwrap();
@@ -662,20 +714,19 @@ mod tests {
let cache = FileMetadataCache::new(config, 1000);
// Precargar directorio recursivamente
let count = cache.preload_directory(temp_dir.path(), true, 2).await.unwrap();
assert_eq!(count, 3); // dir, subdir, 2 files
// preload_directory caches the *contents* of the directory, not the root itself
let count = cache.preload_directory(&base_path, true, 2).await.unwrap();
assert_eq!(count, 3); // subdir, file1, file2
// Verificar existencia en caché
assert_eq!(cache.is_dir(temp_dir.path()).await, Some(true));
// Verificar existencia en caché (solo contenido, no la raíz)
assert_eq!(cache.is_dir(&sub_dir).await, Some(true));
assert_eq!(cache.is_file(&file1).await, Some(true));
assert_eq!(cache.is_file(&file2).await, Some(true));
// Invalidar directorio y contenido
cache.invalidate_directory(temp_dir.path()).await;
cache.invalidate_directory(&base_path).await;
// Verificar que nada existe en caché
assert!(cache.exists(temp_dir.path()).await.is_none());
assert!(cache.exists(&sub_dir).await.is_none());
assert!(cache.exists(&file1).await.is_none());
assert!(cache.exists(&file2).await.is_none());
@@ -515,6 +515,7 @@ impl Clone for IdMappingService {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::tempdir;
#[tokio::test]
@@ -18,6 +18,14 @@ use bytes::Bytes;
use lru::LruCache;
use std::num::NonZeroUsize;
use image::{ImageFormat, DynamicImage};
use async_trait::async_trait;
use crate::application::ports::transcode_ports::{
ImageTranscodePort,
OutputFormat as PortOutputFormat,
TranscodeStatsDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Maximum file size for transcoding (5MB - larger files stream directly)
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
@@ -354,6 +362,59 @@ impl ImageTranscodeService {
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
/// Convert port OutputFormat to infra OutputFormat.
impl From<PortOutputFormat> for OutputFormat {
fn from(fmt: PortOutputFormat) -> Self {
match fmt {
PortOutputFormat::WebP => OutputFormat::WebP,
}
}
}
#[async_trait]
impl ImageTranscodePort for ImageTranscodeService {
fn can_transcode(&self, mime_type: &str) -> bool {
ImageTranscodeService::can_transcode(mime_type)
}
fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool {
ImageTranscodeService::should_transcode(mime_type, file_size)
}
async fn get_transcoded(
&self,
file_id: &str,
original_content: &[u8],
original_mime: &str,
target_format: PortOutputFormat,
) -> Result<(Bytes, String, bool), DomainError> {
self.get_transcoded(file_id, original_content, original_mime, target_format.into())
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ImageTranscode", e))
}
async fn invalidate(&self, file_id: &str) {
self.invalidate(file_id).await
}
async fn get_stats(&self) -> TranscodeStatsDto {
let stats = self.get_stats().await;
TranscodeStatsDto {
cache_hits: stats.cache_hits,
disk_hits: stats.disk_hits,
transcodes: stats.transcodes,
bytes_saved: stats.bytes_saved,
transcode_errors: stats.transcode_errors,
}
}
async fn clear_cache(&self) -> Result<(), DomainError> {
self.clear_cache().await.map_err(DomainError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -2,7 +2,6 @@ pub mod file_system_i18n_service;
pub mod file_system_utils;
pub mod id_mapping_service;
pub mod id_mapping_optimizer;
pub mod cache_manager;
pub mod file_metadata_cache;
pub mod file_content_cache;
pub mod compression_service;
@@ -19,6 +19,14 @@ use image::{ImageFormat, imageops::FilterType};
use lru::LruCache;
use std::num::NonZeroUsize;
use bytes::Bytes;
use async_trait::async_trait;
use crate::application::ports::thumbnail_ports::{
ThumbnailPort,
ThumbnailSize as PortThumbnailSize,
ThumbnailStatsDto,
};
use crate::domain::errors::{DomainError, ErrorKind};
/// Thumbnail sizes supported by the system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -329,6 +337,60 @@ impl ThumbnailService {
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
/// Convert port ThumbnailSize to infra ThumbnailSize.
impl From<PortThumbnailSize> for ThumbnailSize {
fn from(size: PortThumbnailSize) -> Self {
match size {
PortThumbnailSize::Icon => ThumbnailSize::Icon,
PortThumbnailSize::Preview => ThumbnailSize::Preview,
PortThumbnailSize::Large => ThumbnailSize::Large,
}
}
}
#[async_trait]
impl ThumbnailPort for ThumbnailService {
fn is_supported_image(&self, mime_type: &str) -> bool {
ThumbnailService::is_supported_image(mime_type)
}
async fn get_thumbnail(
&self,
file_id: &str,
size: PortThumbnailSize,
original_path: &Path,
) -> Result<Bytes, DomainError> {
self.get_thumbnail(file_id, size.into(), original_path)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string()))
}
fn generate_all_sizes_background(
self: Arc<Self>,
file_id: String,
original_path: PathBuf,
) {
ThumbnailService::generate_all_sizes_background(self, file_id, original_path)
}
async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError> {
self.delete_thumbnails(file_id)
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string()))
}
async fn get_stats(&self) -> ThumbnailStatsDto {
let stats = self.get_stats().await;
ThumbnailStatsDto {
cached_thumbnails: stats.cached_thumbnails,
cache_size_bytes: stats.cache_size_bytes,
max_cache_bytes: stats.max_cache_bytes,
}
}
}
/// Thumbnail service errors
#[derive(Debug, thiserror::Error)]
pub enum ThumbnailError {
@@ -78,8 +78,8 @@ impl TrashCleanupService {
// Eliminar cada elemento expirado
for item in expired_items {
let trash_id = item.id.to_string();
let user_id = item.user_id.to_string();
let trash_id = item.id().to_string();
let user_id = item.user_id().to_string();
debug!("Eliminando elemento expirado: id={}, user={}", trash_id, user_id);
@@ -19,6 +19,10 @@ use tokio::sync::{RwLock, mpsc};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use bytes::Bytes;
use async_trait::async_trait;
use crate::application::ports::cache_ports::{WriteBehindCachePort, WriteBehindStatsDto};
use crate::domain::errors::DomainError;
/// Maximum size for write-behind cache (files larger bypass cache)
const WRITE_BEHIND_MAX_SIZE: usize = 1024 * 1024; // 1MB
@@ -364,6 +368,56 @@ impl WriteBehindCache {
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
#[async_trait]
impl WriteBehindCachePort for WriteBehindCache {
fn is_eligible_size(&self, size: usize) -> bool {
WriteBehindCache::is_eligible(size)
}
async fn put_pending(
&self,
file_id: String,
content: Bytes,
target_path: PathBuf,
) -> Result<bool, DomainError> {
self.put_pending(file_id, content, target_path).await.map_err(DomainError::from)
}
async fn get_pending(&self, file_id: &str) -> Option<Bytes> {
self.get_pending(file_id).await
}
async fn is_pending(&self, file_id: &str) -> bool {
self.is_pending(file_id).await
}
async fn force_flush(&self, file_id: &str) -> Result<(), DomainError> {
self.force_flush(file_id).await.map_err(DomainError::from)
}
async fn flush_all(&self) -> Result<(), DomainError> {
self.flush_all().await.map_err(DomainError::from)
}
async fn shutdown(&self) -> Result<(), DomainError> {
self.shutdown().await.map_err(DomainError::from)
}
async fn get_stats(&self) -> WriteBehindStatsDto {
let stats = self.get_stats().await;
WriteBehindStatsDto {
pending_count: stats.pending_count,
pending_bytes: stats.pending_bytes,
total_writes: stats.total_writes,
total_bytes_written: stats.total_bytes_written,
cache_hits: stats.cache_hits,
avg_flush_time_us: stats.avg_flush_time_us,
}
}
}
impl Default for WriteBehindCache {
fn default() -> Self {
// Note: This creates a non-Arc version, prefer using new()
+19 -3
View File
@@ -2,10 +2,13 @@ use std::io::{Cursor, Read, Write};
use zip::{ZipWriter, write::SimpleFileOptions};
use thiserror::Error;
use tracing::*;
use async_trait::async_trait;
use crate::{
application::dtos::file_dto::FileDto,
application::dtos::folder_dto::FolderDto,
application::ports::inbound::{FileUseCase, FolderUseCase},
application::ports::inbound::FolderUseCase,
application::ports::file_ports::FileRetrievalUseCase,
application::ports::zip_ports::ZipPort,
common::errors::{Result, DomainError, ErrorKind},
};
use std::sync::Arc;
@@ -45,13 +48,13 @@ impl From<zip::result::ZipError> for DomainError {
/// Servicio para crear archivos ZIP
pub struct ZipService {
file_service: Arc<dyn FileUseCase>,
file_service: Arc<dyn FileRetrievalUseCase>,
folder_service: Arc<dyn FolderUseCase>,
}
impl ZipService {
/// Crea una nueva instancia del servicio ZIP con una referencia al servicio de archivos
pub fn new(file_service: Arc<dyn FileUseCase>, folder_service: Arc<dyn FolderUseCase>) -> Self {
pub fn new(file_service: Arc<dyn FileRetrievalUseCase>, folder_service: Arc<dyn FolderUseCase>) -> Self {
Self {
file_service,
folder_service,
@@ -225,4 +228,17 @@ impl ZipService {
}
}
}
}
// ─── Port implementation ─────────────────────────────────────────────────────
#[async_trait]
impl ZipPort for ZipService {
async fn create_folder_zip(
&self,
folder_id: &str,
folder_name: &str,
) -> std::result::Result<Vec<u8>, DomainError> {
self.create_folder_zip(folder_id, folder_name).await
}
}