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:
Cameron
2026-04-18 21:38:51 -04:00
committed by cameron
parent 4a775b5e9b
commit 3027a3ffda
3 changed files with 235 additions and 112 deletions

View File

@@ -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)]