perf: DB-backed recursive /photos + watcher reconciliation
Recursive listings now query image_exif instead of walking disk, taking union-mode /photos from ~17s to sub-second on a 10k-file library. The watcher's full scan prunes stale image_exif rows so the DB stays in parity with the filesystem when files are deleted externally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -385,6 +385,28 @@ pub trait ExifDao: Sync + Send {
|
||||
context: &opentelemetry::Context,
|
||||
hash: &str,
|
||||
) -> Result<Vec<String>, DbError>;
|
||||
|
||||
/// List `(library_id, rel_path)` pairs for the given libraries, optionally
|
||||
/// restricted to rows whose rel_path starts with `path_prefix`. When
|
||||
/// `library_ids` is empty, rows from every library are returned. Used by
|
||||
/// `/photos` recursive listing to skip the filesystem walk — the watcher
|
||||
/// keeps image_exif in parity with disk via the reconciliation pass.
|
||||
fn list_rel_paths_for_libraries(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_ids: &[i32],
|
||||
path_prefix: Option<&str>,
|
||||
) -> Result<Vec<(i32, String)>, DbError>;
|
||||
|
||||
/// Delete a single image_exif row scoped to `(library_id, rel_path)`.
|
||||
/// Distinct from `delete_exif`, which matches on rel_path alone and
|
||||
/// would clobber same-named files across libraries.
|
||||
fn delete_exif_by_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
) -> Result<(), DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteExifDao {
|
||||
@@ -933,6 +955,59 @@ impl ExifDao for SqliteExifDao {
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_rel_paths_for_libraries(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_ids: &[i32],
|
||||
path_prefix: Option<&str>,
|
||||
) -> Result<Vec<(i32, String)>, DbError> {
|
||||
trace_db_call(context, "query", "list_rel_paths_for_libraries", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
let mut query = image_exif.select((library_id, rel_path)).into_boxed();
|
||||
|
||||
if !library_ids.is_empty() {
|
||||
query = query.filter(library_id.eq_any(library_ids.to_vec()));
|
||||
}
|
||||
|
||||
if let Some(prefix) = path_prefix.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
// Trailing slash normalization so "2024" matches "2024/..."
|
||||
// without also matching "2024-archive/...".
|
||||
let prefix = prefix.trim_end_matches('/');
|
||||
let pattern = format!("{}/%", prefix.replace('%', "\\%").replace('_', "\\_"));
|
||||
query = query.filter(rel_path.like(pattern).escape('\\'));
|
||||
}
|
||||
|
||||
query
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn delete_exif_by_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_val: i32,
|
||||
rel_path_val: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "delete", "delete_exif_by_library", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
diesel::delete(
|
||||
image_exif
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.filter(rel_path.eq(rel_path_val)),
|
||||
)
|
||||
.execute(self.connection.lock().unwrap().deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|_| anyhow::anyhow!("Delete error"))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user