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:
@@ -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}")))?;
|
||||||
|
|||||||
Reference in New Issue
Block a user