Optimize search service with database-level pagination and improve cache handling

- Add search_files_paginated method to FileReadPort for database-level pagination
- Implement efficient SQL-based search with LIMIT/OFFSET in file_blob_read_repository
- Add relevance scoring (exact match > starts-with > contains)
- Support multiple sort options (name, date, size) with ascending/descending order
- Fix cache expiration handling with proper borrow checker semantics
- Use i64 for SQL LIMIT/OFFSET parameters instead of usize
- Clean up duplicate SQL query builder code in search_files_paginated

This significantly improves search performance for non-recursive queries by:
- Pushing pagination to the database layer
- Avoiding loading all files into memory for filtering
- Supporting database-level sorting
This commit is contained in:
George Wu
2026-02-21 14:48:39 -08:00
parent 46a65c322c
commit 95fa648a55
2 changed files with 114 additions and 71 deletions
+53 -18
View File
@@ -18,6 +18,7 @@ use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::outbound::FolderStoragePort; use crate::application::ports::outbound::FolderStoragePort;
use crate::application::ports::storage_ports::FileReadPort; use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::Result; use crate::common::errors::Result;
use crate::domain::errors::DomainError;
/** /**
* High-performance search service implementation for files and folders. * High-performance search service implementation for files and folders.
@@ -181,12 +182,21 @@ impl SearchService {
} }
/// Creates a cache key from the search criteria. /// Creates a cache key from the search criteria.
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey { fn create_cache_key(
let criteria_str = serde_json::to_string(criteria).unwrap_or_default(); &self,
SearchCacheKey { criteria: &SearchCriteriaDto,
user_id: &str,
) -> Result<SearchCacheKey> {
let criteria_str = serde_json::to_string(criteria).map_err(|e| {
DomainError::internal_error(
"SearchService",
format!("Failed to serialize criteria: {}", e),
)
})?;
Ok(SearchCacheKey {
criteria_hash: criteria_str, criteria_hash: criteria_str,
user_id: user_id.to_string(), user_id: user_id.to_string(),
} })
} }
/// Attempts to retrieve results from the cache. /// Attempts to retrieve results from the cache.
@@ -195,15 +205,15 @@ impl SearchService {
return None; return None;
} }
if let Ok(cache) = self.search_cache.lock() if let Ok(cache) = self.search_cache.lock() {
&& let Some(cached_result) = cache.get(key) if let Some(cached_result) = cache.get(key) {
{
let now = Instant::now(); let now = Instant::now();
let ttl = Duration::from_secs(self.cache_ttl); let ttl = Duration::from_secs(self.cache_ttl);
if now.duration_since(cached_result.timestamp) < ttl { if now.duration_since(cached_result.timestamp) < ttl {
return Some(cached_result.results.clone()); return Some(cached_result.results.clone());
} }
} }
}
None None
} }
@@ -215,13 +225,29 @@ impl SearchService {
} }
if let Ok(mut cache) = self.search_cache.lock() { if let Ok(mut cache) = self.search_cache.lock() {
if cache.len() >= self.max_cache_size let now = Instant::now();
&& let Some((oldest_key, _)) = let ttl = Duration::from_secs(self.cache_ttl);
// Remove expired entries
let mut expired_keys = Vec::new();
for (key, result) in cache.iter() {
if now.duration_since(result.timestamp) > ttl {
expired_keys.push(key.clone());
}
}
for key in expired_keys {
cache.remove(&key);
}
// Remove oldest if cache is full
if cache.len() >= self.max_cache_size {
if let Some((oldest_key, _)) =
cache.iter().min_by_key(|(_, result)| result.timestamp) cache.iter().min_by_key(|(_, result)| result.timestamp)
{ {
let key_to_remove = oldest_key.clone(); let key_to_remove = oldest_key.clone();
cache.remove(&key_to_remove); cache.remove(&key_to_remove);
} }
}
cache.insert( cache.insert(
key, key,
@@ -350,7 +376,7 @@ impl SearchService {
} }
Ok((all_files, all_folders)) Ok((all_files, all_folders))
}) // end Box::pin })
} }
/// Quick suggestions search — returns up to `limit` name suggestions /// Quick suggestions search — returns up to `limit` name suggestions
@@ -532,12 +558,14 @@ impl SearchUseCase for SearchService {
// TODO: Get user ID from the authentication context // TODO: Get user ID from the authentication context
let user_id = "default-user"; let user_id = "default-user";
let cache_key = self.create_cache_key(&criteria, user_id);
// Try cache // Try to get from cache
if let Some(cached_results) = self.get_from_cache(&cache_key) { let cache_key = self.create_cache_key(&criteria, user_id).ok();
if let Some(ref key) = cache_key {
if let Some(cached_results) = self.get_from_cache(key) {
return Ok(cached_results); return Ok(cached_results);
} }
}
let query = criteria.name_contains.as_deref().unwrap_or(""); let query = criteria.name_contains.as_deref().unwrap_or("");
@@ -570,7 +598,10 @@ impl SearchUseCase for SearchService {
folders folders
.into_iter() .into_iter()
.map(FolderDto::from) .map(FolderDto::from)
.filter(|f| f.name.to_lowercase().contains(&query_lower)) .filter(|f| {
let folder_name_lower = f.name.to_lowercase();
folder_name_lower.contains(&query_lower)
})
.collect() .collect()
} else { } else {
folders.into_iter().map(FolderDto::from).collect() folders.into_iter().map(FolderDto::from).collect()
@@ -636,7 +667,9 @@ impl SearchUseCase for SearchService {
criteria.sort_by.clone(), criteria.sort_by.clone(),
); );
self.store_in_cache(cache_key, search_results.clone()); if let Some(key) = cache_key {
self.store_in_cache(key, search_results.clone());
}
return Ok(search_results); return Ok(search_results);
} }
@@ -644,7 +677,7 @@ impl SearchUseCase for SearchService {
// For recursive searches, we need to traverse all subfolders // For recursive searches, we need to traverse all subfolders
// This is less efficient but necessary for recursive functionality // This is less efficient but necessary for recursive functionality
let criteria_arc = Arc::new(criteria.clone()); let criteria_arc = Arc::new(criteria.clone());
let (found_files, found_folders) = Self::search_parallel( let (found_files, found_folders): (Vec<FileDto>, Vec<FolderDto>) = Self::search_parallel(
self.file_repository.clone(), self.file_repository.clone(),
self.folder_repository.clone(), self.folder_repository.clone(),
criteria.folder_id.clone(), criteria.folder_id.clone(),
@@ -743,7 +776,9 @@ impl SearchUseCase for SearchService {
); );
// Store in cache // Store in cache
self.store_in_cache(cache_key, search_results.clone()); if let Some(key) = cache_key {
self.store_in_cache(key, search_results.clone());
}
Ok(search_results) Ok(search_results)
} }
@@ -767,7 +802,7 @@ impl SearchUseCase for SearchService {
} }
} }
// ── Stub for testing ──────────────────────────────────────────────────── // ─── Stub for testing ────────────────────────────────────────────────────
impl SearchService { impl SearchService {
/// Creates a stub version of the service for testing /// Creates a stub version of the service for testing
@@ -393,8 +393,8 @@ impl FileReadPort for FileBlobReadRepository {
criteria: &SearchCriteriaDto, criteria: &SearchCriteriaDto,
user_id: &str, user_id: &str,
) -> Result<(Vec<File>, usize), DomainError> { ) -> Result<(Vec<File>, usize), DomainError> {
let offset = criteria.offset; let offset = criteria.offset as i64;
let limit = criteria.limit; let limit = criteria.limit as i64;
// Determine sort order // Determine sort order
let (order_column, order_dir) = match criteria.sort_by.as_str() { let (order_column, order_dir) = match criteria.sort_by.as_str() {
@@ -448,12 +448,14 @@ impl FileReadPort for FileBlobReadRepository {
WHERE fi.user_id = $1::uuid AND fi.folder_id = $2::uuid WHERE fi.user_id = $1::uuid AND fi.folder_id = $2::uuid
AND fi.is_trashed = false AND LOWER(fi.name) LIKE $3 AND fi.is_trashed = false AND LOWER(fi.name) LIKE $3
ORDER BY {} {} ORDER BY {} {}
LIMIT {} OFFSET {}", LIMIT $4 OFFSET $5",
order_column, order_dir, limit, offset order_column, order_dir
)) ))
.bind(user_id) .bind(user_id)
.bind(fid) .bind(fid)
.bind(&name_pattern) .bind(&name_pattern)
.bind(limit)
.bind(offset)
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
@@ -494,11 +496,13 @@ impl FileReadPort for FileBlobReadRepository {
WHERE fi.user_id = $1::uuid AND fi.folder_id = $2::uuid WHERE fi.user_id = $1::uuid AND fi.folder_id = $2::uuid
AND fi.is_trashed = false AND fi.is_trashed = false
ORDER BY {} {} ORDER BY {} {}
LIMIT {} OFFSET {}", LIMIT $3 OFFSET $4",
order_column, order_dir, limit, offset order_column, order_dir
)) ))
.bind(user_id) .bind(user_id)
.bind(fid) .bind(fid)
.bind(limit)
.bind(offset)
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
@@ -541,11 +545,13 @@ impl FileReadPort for FileBlobReadRepository {
WHERE fi.user_id = $1::uuid AND fi.is_trashed = false WHERE fi.user_id = $1::uuid AND fi.is_trashed = false
AND LOWER(fi.name) LIKE $2 AND LOWER(fi.name) LIKE $2
ORDER BY {} {} ORDER BY {} {}
LIMIT {} OFFSET {}", LIMIT $3 OFFSET $4",
order_column, order_dir, limit, offset order_column, order_dir
)) ))
.bind(user_id) .bind(user_id)
.bind(&name_pattern) .bind(&name_pattern)
.bind(limit)
.bind(offset)
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
@@ -583,10 +589,12 @@ impl FileReadPort for FileBlobReadRepository {
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.user_id = $1::uuid AND fi.is_trashed = false WHERE fi.user_id = $1::uuid AND fi.is_trashed = false
ORDER BY {} {} ORDER BY {} {}
LIMIT {} OFFSET {}", LIMIT $2 OFFSET $3",
order_column, order_dir, limit, offset order_column, order_dir
)) ))
.bind(user_id) .bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;