From 3f3ffa541a1d3466fa3723dace04d45409792764 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH] perf(engine): #1136 album order and album commands without per-photo scans A manual album shown in its own order sorted with position() per photo (O(n^2): 2.0 s for 20k photos, now 18 ms). album.addPhotos, removePhotos and toggleTarget test membership with sets. The 100k scale test measures all three. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 12 +++++++++--- crates/engine/src/lib.rs | 8 ++++++-- crates/engine/src/tests_library.rs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f6..9a5cb410b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1147,8 +1147,10 @@ pub fn specs() -> Vec { let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; let mut photos = al.photos.clone(); let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); for t in targets { - if !photos.contains(&t) { + if held.insert(t) { photos.push(t); } } @@ -1179,11 +1181,14 @@ pub fn specs() -> Vec { } }; let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); @@ -1317,6 +1322,7 @@ pub fn specs() -> Vec { if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); } + let targets: std::collections::HashSet = targets.into_iter().collect(); let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..09fb12f2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -874,8 +874,12 @@ impl Session { && !al.is_smart() && self.filter == Filter::default() { - let order = al.photos.clone(); - visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + // each photo's place looked up, not searched for (a big album sorted in O(n log n)) + let mut place: std::collections::HashMap = std::collections::HashMap::with_capacity(al.photos.len()); + for (i, id) in al.photos.iter().enumerate() { + place.entry(*id).or_insert(i); + } + visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..0fa72e453 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -440,6 +440,21 @@ fn scale_100k_library_queries() { m("undo", &mut || { s.execute("edit.undo", &serde_json::json!({})).unwrap(); }); + // a 20k-photo album shown in its own order (capture-date sort, no filter) + let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec = (0..n).rev().step_by(5).map(|i| i + 1).collect(); + m("add 20k to an album", &mut || { + s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap(); + }); + m("album of 20k (album order)", &mut || { + s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap(); + let _ = s.visible_cloned(); + }); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + let half: Vec = ids.iter().copied().step_by(2).collect(); + m("remove 10k from the album", &mut || { + s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap(); + }); worst.sort_by(|a, b| b.1.total_cmp(&a.1)); eprintln!("slowest: {:?}", &worst[..3]); }