perf: Arc-wrap SearchResultsDto for zero-copy cache reads

- Change SearchUseCase::search() return type to Arc<SearchResultsDto>
- Replace Cache<u64, SearchResultsDto> with Cache<u64, Arc<SearchResultsDto>>
- Eliminate .clone() on full result set; use Arc::clone() (ptr bump)
- Remove Clone derive from SearchResultsDto (no longer needed)
- Update handlers to deref Arc for JSON serialization
This commit is contained in:
Dionisio
2026-02-25 23:47:51 +01:00
parent 5a1959bf23
commit 5b55056921
5 changed files with 24 additions and 21 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ pub struct SearchFolderResultDto {
* both files and folders that match the search criteria, along with pagination * both files and folders that match the search criteria, along with pagination
* information and server-computed metadata. * information and server-computed metadata.
*/ */
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct SearchResultsDto { pub struct SearchResultsDto {
/// Files matching the search criteria (enriched with metadata) /// Files matching the search criteria (enriched with metadata)
pub files: Vec<SearchFileResultDto>, pub files: Vec<SearchFileResultDto>,
+4 -1
View File
@@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc;
use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
@@ -92,13 +93,15 @@ pub trait FolderUseCase: Send + Sync + 'static {
pub trait SearchUseCase: Send + Sync + 'static { pub trait SearchUseCase: Send + Sync + 'static {
/// Performs a full search based on the specified criteria. /// Performs a full search based on the specified criteria.
/// ///
/// Returns `Arc<SearchResultsDto>` so the cache and the caller share
/// the same allocation — zero-copy on both insert and hit.
/// `user_id` identifies the authenticated user so that SQL queries filter /// `user_id` identifies the authenticated user so that SQL queries filter
/// by owner and the result cache is isolated per tenant. /// by owner and the result cache is isolated per tenant.
async fn search( async fn search(
&self, &self,
criteria: SearchCriteriaDto, criteria: SearchCriteriaDto,
user_id: &str, user_id: &str,
) -> Result<SearchResultsDto, DomainError>; ) -> Result<Arc<SearchResultsDto>, DomainError>;
/// Returns quick suggestions for autocomplete (lightweight, fast). /// Returns quick suggestions for autocomplete (lightweight, fast).
async fn suggest( async fn suggest(
+15 -15
View File
@@ -42,9 +42,9 @@ pub struct SearchService {
folder_repository: Arc<dyn FolderStoragePort>, folder_repository: Arc<dyn FolderStoragePort>,
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka). /// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
/// Uses `future::Cache` so `.get()` / `.insert()` never block the Tokio /// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
/// runtime (maintenance work is deferred to an internal async task). /// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
search_cache: moka::future::Cache<u64, SearchResultsDto>, search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
} }
// ─── Utility functions (pure, no self — computed on the server) ───────── // ─── Utility functions (pure, no self — computed on the server) ─────────
@@ -134,12 +134,12 @@ impl SearchService {
} }
/// Attempts to retrieve results from the cache. /// Attempts to retrieve results from the cache.
async fn get_from_cache(&self, key: u64) -> Option<SearchResultsDto> { async fn get_from_cache(&self, key: u64) -> Option<Arc<SearchResultsDto>> {
self.search_cache.get(&key).await self.search_cache.get(&key).await
} }
/// Stores results in the cache. /// Stores results in the cache.
async fn store_in_cache(&self, key: u64, results: SearchResultsDto) { async fn store_in_cache(&self, key: u64, results: Arc<SearchResultsDto>) {
self.search_cache.insert(key, results).await; self.search_cache.insert(key, results).await;
} }
@@ -272,7 +272,7 @@ impl SearchUseCase for SearchService {
* - Human-readable size formatting * - Human-readable size formatting
* - Pagination * - Pagination
*/ */
async fn search(&self, criteria: SearchCriteriaDto, user_id: &str) -> Result<SearchResultsDto> { async fn search(&self, criteria: SearchCriteriaDto, user_id: &str) -> Result<Arc<SearchResultsDto>> {
let start = Instant::now(); let start = Instant::now();
// Try to get from cache // Try to get from cache
@@ -359,7 +359,7 @@ impl SearchUseCase for SearchService {
let elapsed_ms = start.elapsed().as_millis() as u64; let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = SearchResultsDto::new( let search_results = Arc::new(SearchResultsDto::new(
paginated_files, paginated_files,
paginated_folders, paginated_folders,
criteria.limit, criteria.limit,
@@ -367,9 +367,9 @@ impl SearchUseCase for SearchService {
Some(total_count), Some(total_count),
elapsed_ms, elapsed_ms,
criteria.sort_by.clone(), criteria.sort_by.clone(),
); ));
self.store_in_cache(cache_key, search_results.clone()).await; self.store_in_cache(cache_key, Arc::clone(&search_results)).await;
return Ok(search_results); return Ok(search_results);
} }
@@ -443,7 +443,7 @@ impl SearchUseCase for SearchService {
let elapsed_ms = start.elapsed().as_millis() as u64; let elapsed_ms = start.elapsed().as_millis() as u64;
let search_results = SearchResultsDto::new( let search_results = Arc::new(SearchResultsDto::new(
paginated_files, paginated_files,
paginated_folders, paginated_folders,
criteria.limit, criteria.limit,
@@ -451,10 +451,10 @@ impl SearchUseCase for SearchService {
Some(total_count), Some(total_count),
elapsed_ms, elapsed_ms,
criteria.sort_by.clone(), criteria.sort_by.clone(),
); ));
// Store in cache // Store in cache — Arc::clone is ~1 ns (atomic increment)
self.store_in_cache(cache_key, search_results.clone()).await; self.store_in_cache(cache_key, Arc::clone(&search_results)).await;
Ok(search_results) Ok(search_results)
} }
@@ -490,8 +490,8 @@ impl SearchService {
&self, &self,
_criteria: SearchCriteriaDto, _criteria: SearchCriteriaDto,
_user_id: &str, _user_id: &str,
) -> Result<SearchResultsDto> { ) -> Result<Arc<SearchResultsDto>> {
Ok(SearchResultsDto::empty()) Ok(Arc::new(SearchResultsDto::empty()))
} }
async fn suggest( async fn suggest(
+2 -2
View File
@@ -597,8 +597,8 @@ impl SearchUseCase for StubSearchUseCase {
&self, &self,
_criteria: SearchCriteriaDto, _criteria: SearchCriteriaDto,
_user_id: &str, _user_id: &str,
) -> Result<SearchResultsDto, DomainError> { ) -> Result<Arc<SearchResultsDto>, DomainError> {
Ok(SearchResultsDto::empty()) Ok(Arc::new(SearchResultsDto::empty()))
} }
async fn suggest( async fn suggest(
@@ -67,7 +67,7 @@ impl SearchHandler {
results.files.len(), results.files.len(),
results.folders.len() results.folders.len()
); );
(StatusCode::OK, Json(results)).into_response() (StatusCode::OK, Json(&*results)).into_response()
} }
Err(err) => { Err(err) => {
error!("Search error: {}", err); error!("Search error: {}", err);
@@ -108,7 +108,7 @@ impl SearchHandler {
results.files.len(), results.files.len(),
results.folders.len() results.folders.len()
); );
(StatusCode::OK, Json(results)).into_response() (StatusCode::OK, Json(&*results)).into_response()
} }
Err(err) => { Err(err) => {
error!("Search error: {}", err); error!("Search error: {}", err);