Add database optimizations for photo search and pagination
Implement database-level sorting with composite indexes for efficient date and tag queries. Add pagination metadata support and optimize tag count queries using batch processing.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
-- Revert search performance optimization indexes
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_image_exif_date_path;
|
||||||
|
DROP INDEX IF EXISTS idx_tagged_photo_count;
|
||||||
15
migrations/2026-01-18-000000_optimize_photo_search/up.sql
Normal file
15
migrations/2026-01-18-000000_optimize_photo_search/up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add composite indexes for search performance optimization
|
||||||
|
-- This migration addresses N+1 query issues and enables database-level sorting
|
||||||
|
|
||||||
|
-- Covering index for date-sorted queries (supports ORDER BY + pagination)
|
||||||
|
-- Enables efficient date-based sorting without loading all files into memory
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_exif_date_path
|
||||||
|
ON image_exif(date_taken DESC, file_path);
|
||||||
|
|
||||||
|
-- Optimize batch tag count queries with GROUP BY
|
||||||
|
-- Reduces N individual queries to a single batch query
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tagged_photo_count
|
||||||
|
ON tagged_photo(photo_name, tag_id);
|
||||||
|
|
||||||
|
-- Update query planner statistics to optimize query execution
|
||||||
|
ANALYZE;
|
||||||
@@ -101,6 +101,16 @@ impl FromRequest for Claims {
|
|||||||
pub struct PhotosResponse {
|
pub struct PhotosResponse {
|
||||||
pub photos: Vec<String>,
|
pub photos: Vec<String>,
|
||||||
pub dirs: Vec<String>,
|
pub dirs: Vec<String>,
|
||||||
|
|
||||||
|
// Pagination metadata (only present when limit is set)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total_count: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub has_more: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_offset: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||||||
@@ -141,6 +151,10 @@ pub struct FilesRequest {
|
|||||||
|
|
||||||
// Media type filtering
|
// Media type filtering
|
||||||
pub media_type: Option<MediaType>,
|
pub media_type: Option<MediaType>,
|
||||||
|
|
||||||
|
// Pagination parameters (optional - backward compatible)
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||||||
|
|||||||
@@ -296,6 +296,17 @@ pub trait ExifDao: Sync + Send {
|
|||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
) -> Result<Vec<String>, DbError>;
|
) -> Result<Vec<String>, DbError>;
|
||||||
|
|
||||||
|
/// Get files sorted by date with optional pagination
|
||||||
|
/// Returns (sorted_file_paths, total_count)
|
||||||
|
fn get_files_sorted_by_date(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
ascending: bool,
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<(Vec<String>, i64), DbError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqliteExifDao {
|
pub struct SqliteExifDao {
|
||||||
@@ -581,4 +592,65 @@ impl ExifDao for SqliteExifDao {
|
|||||||
})
|
})
|
||||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_files_sorted_by_date(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
ascending: bool,
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<(Vec<String>, i64), DbError> {
|
||||||
|
trace_db_call(context, "query", "get_files_sorted_by_date", |span| {
|
||||||
|
use diesel::dsl::count_star;
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry::trace::Span;
|
||||||
|
use schema::image_exif::dsl::*;
|
||||||
|
|
||||||
|
span.set_attributes(vec![
|
||||||
|
KeyValue::new("file_count", file_paths.len() as i64),
|
||||||
|
KeyValue::new("ascending", ascending.to_string()),
|
||||||
|
KeyValue::new("limit", limit.map(|l| l.to_string()).unwrap_or_default()),
|
||||||
|
KeyValue::new("offset", offset.to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if file_paths.is_empty() {
|
||||||
|
return Ok((Vec::new(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection = &mut *self.connection.lock().unwrap();
|
||||||
|
|
||||||
|
// Get total count of files that have EXIF data
|
||||||
|
let total_count: i64 = image_exif
|
||||||
|
.filter(file_path.eq_any(file_paths))
|
||||||
|
.select(count_star())
|
||||||
|
.first(connection)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Count query error"))?;
|
||||||
|
|
||||||
|
// Build sorted query
|
||||||
|
let mut query = image_exif.filter(file_path.eq_any(file_paths)).into_boxed();
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
// Note: SQLite NULL handling varies - NULLs appear first for ASC, last for DESC by default
|
||||||
|
if ascending {
|
||||||
|
query = query.order(date_taken.asc());
|
||||||
|
} else {
|
||||||
|
query = query.order(date_taken.desc());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination if requested
|
||||||
|
if let Some(limit_val) = limit {
|
||||||
|
query = query.limit(limit_val).offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute and extract file paths
|
||||||
|
let results: Vec<String> = query
|
||||||
|
.select(file_path)
|
||||||
|
.load::<String>(connection)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Query error"))?;
|
||||||
|
|
||||||
|
Ok((results, total_count))
|
||||||
|
})
|
||||||
|
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
428
src/files.rs
428
src/files.rs
@@ -47,69 +47,142 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
/// Apply sorting to files with EXIF data support for date-based sorting
|
/// Apply sorting to files with EXIF data support for date-based sorting
|
||||||
/// Handles both date sorting (with EXIF/filename fallback) and regular sorting
|
/// Handles both date sorting (with EXIF/filename fallback) and regular sorting
|
||||||
|
/// Returns (sorted_file_paths, total_count)
|
||||||
fn apply_sorting_with_exif(
|
fn apply_sorting_with_exif(
|
||||||
files: Vec<FileWithTagCount>,
|
files: Vec<FileWithTagCount>,
|
||||||
sort_type: SortType,
|
sort_type: SortType,
|
||||||
exif_dao: &mut Box<dyn ExifDao>,
|
exif_dao: &mut Box<dyn ExifDao>,
|
||||||
span_context: &opentelemetry::Context,
|
span_context: &opentelemetry::Context,
|
||||||
base_path: &Path,
|
base_path: &Path,
|
||||||
) -> Vec<String> {
|
limit: Option<i64>,
|
||||||
|
offset: i64,
|
||||||
|
) -> (Vec<String>, i64) {
|
||||||
|
let total_count = files.len() as i64;
|
||||||
|
|
||||||
match sort_type {
|
match sort_type {
|
||||||
SortType::DateTakenAsc | SortType::DateTakenDesc => {
|
SortType::DateTakenAsc | SortType::DateTakenDesc => {
|
||||||
info!("Date sorting requested, fetching EXIF data");
|
info!("Date sorting requested, using database-level sorting");
|
||||||
|
|
||||||
// Collect file paths for batch EXIF query
|
// Collect file paths for batch EXIF query
|
||||||
let file_paths: Vec<String> = files.iter().map(|f| f.file_name.clone()).collect();
|
let file_paths: Vec<String> = files.iter().map(|f| f.file_name.clone()).collect();
|
||||||
|
|
||||||
// Batch fetch EXIF data
|
// Try database-level sorting first (most efficient)
|
||||||
let exif_map: std::collections::HashMap<String, i64> = exif_dao
|
let ascending = sort_type == SortType::DateTakenAsc;
|
||||||
.get_exif_batch(span_context, &file_paths)
|
match exif_dao.get_files_sorted_by_date(
|
||||||
.unwrap_or_default()
|
span_context,
|
||||||
.into_iter()
|
&file_paths,
|
||||||
.filter_map(|exif| exif.date_taken.map(|dt| (exif.file_path, dt)))
|
ascending,
|
||||||
.collect();
|
limit,
|
||||||
|
offset,
|
||||||
// Convert to FileWithMetadata with date fallback logic
|
) {
|
||||||
let files_with_metadata: Vec<FileWithMetadata> = files
|
Ok((sorted_files, db_total)) => {
|
||||||
.into_iter()
|
info!(
|
||||||
.map(|f| {
|
"Database-level date sorting succeeded, returned {} files",
|
||||||
// Try EXIF date first
|
sorted_files.len()
|
||||||
let date_taken = exif_map
|
);
|
||||||
.get(&f.file_name)
|
(sorted_files, db_total)
|
||||||
.copied()
|
}
|
||||||
.or_else(|| {
|
Err(e) => {
|
||||||
// Fallback to filename extraction
|
warn!(
|
||||||
extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp())
|
"Database-level sorting failed: {:?}, falling back to in-memory sort",
|
||||||
})
|
e
|
||||||
.or_else(|| {
|
);
|
||||||
// Fallback to filesystem metadata creation date
|
// Fallback to in-memory sorting with date extraction
|
||||||
let full_path = base_path.join(&f.file_name);
|
let (sorted, _) = in_memory_date_sort(
|
||||||
std::fs::metadata(full_path)
|
files,
|
||||||
.and_then(|md| md.created().or(md.modified()))
|
sort_type,
|
||||||
.ok()
|
exif_dao,
|
||||||
.map(|system_time| {
|
span_context,
|
||||||
<SystemTime as Into<DateTime<Utc>>>::into(system_time)
|
base_path,
|
||||||
.timestamp()
|
limit,
|
||||||
})
|
offset,
|
||||||
});
|
);
|
||||||
|
(sorted, total_count)
|
||||||
FileWithMetadata {
|
}
|
||||||
file_name: f.file_name,
|
}
|
||||||
tag_count: f.tag_count,
|
|
||||||
date_taken,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
sort_with_metadata(files_with_metadata, sort_type)
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Use regular sort for non-date sorting
|
// Use regular sort for non-date sorting
|
||||||
sort(files, sort_type)
|
let sorted = sort(files, sort_type);
|
||||||
|
let result = if let Some(limit_val) = limit {
|
||||||
|
sorted
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit_val as usize)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
sorted
|
||||||
|
};
|
||||||
|
(result, total_count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback in-memory date sorting with EXIF/filename extraction
|
||||||
|
fn in_memory_date_sort(
|
||||||
|
files: Vec<FileWithTagCount>,
|
||||||
|
sort_type: SortType,
|
||||||
|
exif_dao: &mut Box<dyn ExifDao>,
|
||||||
|
span_context: &opentelemetry::Context,
|
||||||
|
base_path: &Path,
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: i64,
|
||||||
|
) -> (Vec<String>, i64) {
|
||||||
|
let total_count = files.len() as i64;
|
||||||
|
let file_paths: Vec<String> = files.iter().map(|f| f.file_name.clone()).collect();
|
||||||
|
|
||||||
|
// Batch fetch EXIF data
|
||||||
|
let exif_map: std::collections::HashMap<String, i64> = exif_dao
|
||||||
|
.get_exif_batch(span_context, &file_paths)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|exif| exif.date_taken.map(|dt| (exif.file_path, dt)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert to FileWithMetadata with date fallback logic
|
||||||
|
let files_with_metadata: Vec<FileWithMetadata> = files
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| {
|
||||||
|
// Try EXIF date first
|
||||||
|
let date_taken = exif_map
|
||||||
|
.get(&f.file_name)
|
||||||
|
.copied()
|
||||||
|
.or_else(|| {
|
||||||
|
// Fallback to filename extraction
|
||||||
|
extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
// Fallback to filesystem metadata creation date
|
||||||
|
let full_path = base_path.join(&f.file_name);
|
||||||
|
std::fs::metadata(full_path)
|
||||||
|
.and_then(|md| md.created().or(md.modified()))
|
||||||
|
.ok()
|
||||||
|
.map(|system_time| {
|
||||||
|
<SystemTime as Into<DateTime<Utc>>>::into(system_time).timestamp()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
FileWithMetadata {
|
||||||
|
file_name: f.file_name,
|
||||||
|
tag_count: f.tag_count,
|
||||||
|
date_taken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sorted = sort_with_metadata(files_with_metadata, sort_type);
|
||||||
|
let result = if let Some(limit_val) = limit {
|
||||||
|
sorted
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit_val as usize)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
sorted
|
||||||
|
};
|
||||||
|
(result, total_count)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
@@ -171,6 +244,23 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
.map(|mt| format!("{:?}", mt))
|
.map(|mt| format!("{:?}", mt))
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
),
|
),
|
||||||
|
// Pagination parameters
|
||||||
|
KeyValue::new("pagination.enabled", req.limit.is_some().to_string()),
|
||||||
|
KeyValue::new(
|
||||||
|
"pagination.limit",
|
||||||
|
req.limit.map(|l| l.to_string()).unwrap_or_default(),
|
||||||
|
),
|
||||||
|
KeyValue::new("pagination.offset", req.offset.unwrap_or(0).to_string()),
|
||||||
|
// Optimization flags
|
||||||
|
KeyValue::new("optimization.batch_tags", "true"),
|
||||||
|
KeyValue::new(
|
||||||
|
"optimization.db_sort",
|
||||||
|
matches!(
|
||||||
|
req.sort,
|
||||||
|
Some(SortType::DateTakenAsc | SortType::DateTakenDesc)
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let span_context = opentelemetry::Context::current_with_span(span);
|
let span_context = opentelemetry::Context::current_with_span(span);
|
||||||
@@ -332,8 +422,10 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
.collect::<Vec<FileWithTagCount>>()
|
.collect::<Vec<FileWithTagCount>>()
|
||||||
})
|
})
|
||||||
.map(|files| {
|
.map(|files| {
|
||||||
// Handle sorting - use helper function that supports EXIF date sorting
|
// Handle sorting - use helper function that supports EXIF date sorting and pagination
|
||||||
let sort_type = req.sort.unwrap_or(NameAsc);
|
let sort_type = req.sort.unwrap_or(NameAsc);
|
||||||
|
let limit = req.limit;
|
||||||
|
let offset = req.offset.unwrap_or(0);
|
||||||
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
||||||
let result = apply_sorting_with_exif(
|
let result = apply_sorting_with_exif(
|
||||||
files,
|
files,
|
||||||
@@ -341,25 +433,50 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
&mut exif_dao_guard,
|
&mut exif_dao_guard,
|
||||||
&span_context,
|
&span_context,
|
||||||
app_state.base_path.as_ref(),
|
app_state.base_path.as_ref(),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
);
|
);
|
||||||
drop(exif_dao_guard);
|
drop(exif_dao_guard);
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
.inspect(|files| debug!("Found {:?} files", files.len()))
|
.inspect(|(files, total)| debug!("Found {:?} files (total: {})", files.len(), total))
|
||||||
.map(|tagged_files: Vec<String>| {
|
.map(|(tagged_files, total_count)| {
|
||||||
info!(
|
info!(
|
||||||
"Found {:?} tagged files: {:?}",
|
"Found {:?} tagged files: {:?}",
|
||||||
tagged_files.len(),
|
tagged_files.len(),
|
||||||
tagged_files
|
tagged_files
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let returned_count = tagged_files.len() as i64;
|
||||||
|
let offset = req.offset.unwrap_or(0);
|
||||||
|
let pagination_metadata = if req.limit.is_some() {
|
||||||
|
(
|
||||||
|
Some(total_count),
|
||||||
|
Some(offset + returned_count < total_count),
|
||||||
|
if offset + returned_count < total_count {
|
||||||
|
Some(offset + returned_count)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
span_context
|
span_context
|
||||||
.span()
|
.span()
|
||||||
.set_attribute(KeyValue::new("file_count", tagged_files.len().to_string()));
|
.set_attribute(KeyValue::new("file_count", tagged_files.len().to_string()));
|
||||||
|
span_context
|
||||||
|
.span()
|
||||||
|
.set_attribute(KeyValue::new("total_count", total_count.to_string()));
|
||||||
span_context.span().set_status(Status::Ok);
|
span_context.span().set_status(Status::Ok);
|
||||||
|
|
||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: tagged_files,
|
photos: tagged_files,
|
||||||
dirs: vec![],
|
dirs: vec![],
|
||||||
|
total_count: pagination_metadata.0,
|
||||||
|
has_more: pagination_metadata.1,
|
||||||
|
next_offset: pagination_metadata.2,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.into_http_internal_err()
|
.into_http_internal_err()
|
||||||
@@ -393,38 +510,99 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
info!("Starting to filter {} files from filesystem", files.len());
|
info!("Starting to filter {} files from filesystem", files.len());
|
||||||
|
let start_filter = std::time::Instant::now();
|
||||||
|
|
||||||
let photos = files
|
// Separate files and directories in a single pass to avoid redundant metadata calls
|
||||||
.iter()
|
let (file_names, dirs): (Vec<String>, Vec<String>) =
|
||||||
.filter(|&f| {
|
files
|
||||||
f.metadata().map_or_else(
|
.iter()
|
||||||
|e| {
|
.fold((Vec::new(), Vec::new()), |(mut files, mut dirs), path| {
|
||||||
error!("Failed getting file metadata: {:?}", e);
|
match path.metadata() {
|
||||||
f.extension().is_some()
|
Ok(md) => {
|
||||||
},
|
let relative =
|
||||||
|md| md.is_file(),
|
path.strip_prefix(&app_state.base_path).unwrap_or_else(|_| {
|
||||||
)
|
panic!(
|
||||||
})
|
"Unable to strip base path {} from file path {}",
|
||||||
.map(|path: &PathBuf| {
|
&app_state.base_path.path(),
|
||||||
let relative = path.strip_prefix(&app_state.base_path).unwrap_or_else(|_| {
|
path.display()
|
||||||
panic!(
|
)
|
||||||
"Unable to strip base path {} from file path {}",
|
});
|
||||||
&app_state.base_path.path(),
|
let relative_str = relative.to_str().unwrap().to_string();
|
||||||
path.display()
|
|
||||||
)
|
if md.is_file() {
|
||||||
|
files.push(relative_str);
|
||||||
|
} else if md.is_dir() {
|
||||||
|
dirs.push(relative_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed getting file metadata: {:?}", e);
|
||||||
|
// Include files without metadata if they have extensions
|
||||||
|
if path.extension().is_some() {
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(&app_state.base_path)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
panic!(
|
||||||
|
"Unable to strip base path {} from file path {}",
|
||||||
|
&app_state.base_path.path(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
files.push(relative.to_str().unwrap().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(files, dirs)
|
||||||
});
|
});
|
||||||
relative.to_path_buf()
|
|
||||||
})
|
|
||||||
.map(|f| f.to_str().unwrap().to_string())
|
|
||||||
.map(|file_name| {
|
|
||||||
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
|
|
||||||
let file_tags = tag_dao
|
|
||||||
.get_tags_for_path(&span_context, &file_name)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"File filtering took {:?}, now fetching tag counts for {} files",
|
||||||
|
start_filter.elapsed(),
|
||||||
|
file_names.len()
|
||||||
|
);
|
||||||
|
let start_tags = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Batch query for tag counts to avoid N+1 queries
|
||||||
|
let tag_counts = {
|
||||||
|
let mut tag_dao_guard = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
tag_dao_guard
|
||||||
|
.get_tag_counts_batch(&span_context, &file_names)
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
info!("Batch tag count query took {:?}", start_tags.elapsed());
|
||||||
|
|
||||||
|
// Also get full tag lists for files that need tag filtering
|
||||||
|
let start_tag_filter = std::time::Instant::now();
|
||||||
|
let file_tags_map: std::collections::HashMap<String, Vec<crate::tags::Tag>> =
|
||||||
|
if req.tag_ids.is_some() || req.exclude_tag_ids.is_some() {
|
||||||
|
info!(
|
||||||
|
"Tag filtering requested, fetching full tag lists for {} files",
|
||||||
|
file_names.len()
|
||||||
|
);
|
||||||
|
let mut tag_dao_guard = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
file_names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|file_name| {
|
||||||
|
tag_dao_guard
|
||||||
|
.get_tags_for_path(&span_context, file_name)
|
||||||
|
.ok()
|
||||||
|
.map(|tags| (file_name.clone(), tags))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
if req.tag_ids.is_some() || req.exclude_tag_ids.is_some() {
|
||||||
|
info!("Full tag list fetch took {:?}", start_tag_filter.elapsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
let photos = file_names
|
||||||
|
.into_iter()
|
||||||
|
.map(|file_name| {
|
||||||
|
let file_tags = file_tags_map.get(&file_name).cloned().unwrap_or_default();
|
||||||
(file_name, file_tags)
|
(file_name, file_tags)
|
||||||
})
|
})
|
||||||
.filter(|(_, file_tags)| {
|
.filter(|(_, file_tags): &(String, Vec<crate::tags::Tag>)| {
|
||||||
if let Some(tag_ids) = &req.tag_ids {
|
if let Some(tag_ids) = &req.tag_ids {
|
||||||
let tag_ids = tag_ids
|
let tag_ids = tag_ids
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -472,16 +650,28 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|(file_name, tags)| FileWithTagCount {
|
.map(
|
||||||
file_name,
|
|(file_name, _tags): (String, Vec<crate::tags::Tag>)| FileWithTagCount {
|
||||||
tag_count: tags.len() as i64,
|
file_name: file_name.clone(),
|
||||||
})
|
tag_count: *tag_counts.get(&file_name).unwrap_or(&0),
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect::<Vec<FileWithTagCount>>();
|
.collect::<Vec<FileWithTagCount>>();
|
||||||
|
|
||||||
info!("After all filters, {} files remain", photos.len());
|
info!(
|
||||||
|
"After all filters, {} files remain (filtering took {:?})",
|
||||||
|
photos.len(),
|
||||||
|
start_filter.elapsed()
|
||||||
|
);
|
||||||
|
|
||||||
// Handle sorting - use helper function that supports EXIF date sorting
|
// Extract pagination parameters
|
||||||
let response_files = if let Some(sort_type) = req.sort {
|
let limit = req.limit;
|
||||||
|
let offset = req.offset.unwrap_or(0);
|
||||||
|
let start_sort = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Handle sorting - use helper function that supports EXIF date sorting and pagination
|
||||||
|
let (response_files, total_count) = if let Some(sort_type) = req.sort {
|
||||||
|
info!("Sorting {} files by {:?}", photos.len(), sort_type);
|
||||||
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
||||||
let result = apply_sorting_with_exif(
|
let result = apply_sorting_with_exif(
|
||||||
photos,
|
photos,
|
||||||
@@ -489,35 +679,68 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
&mut exif_dao_guard,
|
&mut exif_dao_guard,
|
||||||
&span_context,
|
&span_context,
|
||||||
app_state.base_path.as_ref(),
|
app_state.base_path.as_ref(),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
);
|
);
|
||||||
drop(exif_dao_guard);
|
drop(exif_dao_guard);
|
||||||
result
|
result
|
||||||
} else {
|
} else {
|
||||||
// No sorting requested
|
// No sorting requested - apply pagination if requested
|
||||||
photos
|
let total = photos.len() as i64;
|
||||||
.into_iter()
|
let files: Vec<String> = if let Some(limit_val) = limit {
|
||||||
.map(|f| f.file_name)
|
photos
|
||||||
.collect::<Vec<String>>()
|
.into_iter()
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit_val as usize)
|
||||||
|
.map(|f| f.file_name)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
photos.into_iter().map(|f| f.file_name).collect()
|
||||||
|
};
|
||||||
|
(files, total)
|
||||||
};
|
};
|
||||||
|
info!(
|
||||||
|
"Sorting took {:?}, returned {} files (total: {})",
|
||||||
|
start_sort.elapsed(),
|
||||||
|
response_files.len(),
|
||||||
|
total_count
|
||||||
|
);
|
||||||
|
|
||||||
let dirs = files
|
// Note: dirs were already collected during file filtering to avoid redundant metadata calls
|
||||||
.iter()
|
|
||||||
.filter(|&f| f.metadata().is_ok_and(|md| md.is_dir()))
|
// Calculate pagination metadata
|
||||||
.map(|path: &PathBuf| {
|
let returned_count = response_files.len() as i64;
|
||||||
let relative = path.strip_prefix(&app_state.base_path).unwrap();
|
let pagination_metadata = if limit.is_some() {
|
||||||
relative.to_path_buf()
|
(
|
||||||
})
|
Some(total_count),
|
||||||
.map(|f| f.to_str().unwrap().to_string())
|
Some(offset + returned_count < total_count),
|
||||||
.collect::<Vec<String>>();
|
if offset + returned_count < total_count {
|
||||||
|
Some(offset + returned_count)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
span_context
|
span_context
|
||||||
.span()
|
.span()
|
||||||
.set_attribute(KeyValue::new("file_count", files.len().to_string()));
|
.set_attribute(KeyValue::new("file_count", files.len().to_string()));
|
||||||
|
span_context
|
||||||
|
.span()
|
||||||
|
.set_attribute(KeyValue::new("returned_count", returned_count.to_string()));
|
||||||
|
span_context
|
||||||
|
.span()
|
||||||
|
.set_attribute(KeyValue::new("total_count", total_count.to_string()));
|
||||||
span_context.span().set_status(Status::Ok);
|
span_context.span().set_status(Status::Ok);
|
||||||
|
|
||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: response_files,
|
photos: response_files,
|
||||||
dirs,
|
dirs,
|
||||||
|
total_count: pagination_metadata.0,
|
||||||
|
has_more: pagination_metadata.1,
|
||||||
|
next_offset: pagination_metadata.2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -902,7 +1125,7 @@ mod tests {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
data: crate::database::models::InsertImageExif,
|
data: crate::database::models::InsertImageExif,
|
||||||
) -> Result<crate::database::models::ImageExif, crate::database::DbError> {
|
) -> Result<crate::database::models::ImageExif, DbError> {
|
||||||
// Return a dummy ImageExif for tests
|
// Return a dummy ImageExif for tests
|
||||||
Ok(crate::database::models::ImageExif {
|
Ok(crate::database::models::ImageExif {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -1020,6 +1243,19 @@ mod tests {
|
|||||||
) -> Result<Vec<String>, DbError> {
|
) -> Result<Vec<String>, DbError> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_files_sorted_by_date(
|
||||||
|
&mut self,
|
||||||
|
_context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
_ascending: bool,
|
||||||
|
_limit: Option<i64>,
|
||||||
|
_offset: i64,
|
||||||
|
) -> Result<(Vec<String>, i64), DbError> {
|
||||||
|
// For tests, just return all files unsorted
|
||||||
|
let count = file_paths.len() as i64;
|
||||||
|
Ok((file_paths.to_vec(), count))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod api {
|
mod api {
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ async fn favorites(
|
|||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: favorites,
|
photos: favorites,
|
||||||
dirs: Vec::new(),
|
dirs: Vec::new(),
|
||||||
|
total_count: None,
|
||||||
|
has_more: None,
|
||||||
|
next_offset: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
|
|||||||
79
src/tags.rs
79
src/tags.rs
@@ -322,6 +322,11 @@ pub trait TagDao {
|
|||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
) -> anyhow::Result<Vec<String>>;
|
) -> anyhow::Result<Vec<String>>;
|
||||||
|
fn get_tag_counts_batch(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
) -> anyhow::Result<std::collections::HashMap<String, i64>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqliteTagDao {
|
pub struct SqliteTagDao {
|
||||||
@@ -642,6 +647,65 @@ impl TagDao for SqliteTagDao {
|
|||||||
.load(&mut self.connection)
|
.load(&mut self.connection)
|
||||||
.with_context(|| "Unable to get photo names")
|
.with_context(|| "Unable to get photo names")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_tag_counts_batch(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
) -> anyhow::Result<std::collections::HashMap<String, i64>> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
trace_db_call(context, "query", "get_tag_counts_batch", |span| {
|
||||||
|
span.set_attribute(KeyValue::new("file_count", file_paths.len() as i64));
|
||||||
|
|
||||||
|
if file_paths.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
use diesel::dsl::*;
|
||||||
|
use diesel::sql_types::*;
|
||||||
|
|
||||||
|
// Build dynamic query with placeholders
|
||||||
|
let placeholders = std::iter::repeat_n("?", file_paths.len())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let query_str = format!(
|
||||||
|
r#"
|
||||||
|
SELECT photo_name, COUNT(DISTINCT tag_id) as tag_count
|
||||||
|
FROM tagged_photo
|
||||||
|
WHERE photo_name IN ({})
|
||||||
|
GROUP BY photo_name
|
||||||
|
"#,
|
||||||
|
placeholders
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sql_query(query_str).into_boxed();
|
||||||
|
|
||||||
|
// Bind all file paths
|
||||||
|
for path in file_paths {
|
||||||
|
query = query.bind::<Text, _>(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(QueryableByName, Debug)]
|
||||||
|
struct TagCountRow {
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
photo_name: String,
|
||||||
|
#[diesel(sql_type = BigInt)]
|
||||||
|
tag_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute query and convert to HashMap
|
||||||
|
query
|
||||||
|
.load::<TagCountRow>(&mut self.connection)
|
||||||
|
.with_context(|| "Unable to get batch tag counts")
|
||||||
|
.map(|rows| {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| (row.photo_name, row.tag_count))
|
||||||
|
.collect::<HashMap<String, i64>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -818,6 +882,21 @@ mod tests {
|
|||||||
) -> anyhow::Result<Vec<String>> {
|
) -> anyhow::Result<Vec<String>> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_tag_counts_batch(
|
||||||
|
&mut self,
|
||||||
|
_context: &opentelemetry::Context,
|
||||||
|
file_paths: &[String],
|
||||||
|
) -> anyhow::Result<std::collections::HashMap<String, i64>> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut counts = HashMap::new();
|
||||||
|
for path in file_paths {
|
||||||
|
if let Some(tags) = self.tagged_photos.borrow().get(path) {
|
||||||
|
counts.insert(path.clone(), tags.len() as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(counts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user