perf(catalog): #1057 count develop-only ops in a separate library revision

Catalog::library_revision is bumped by every op except develop-only ones
(SetDevelop, SetHistory, PushHistory, SetVersions and batches of them);
revision keeps counting every op. Filter/Sort/RuleSet::reads_develop and
Catalog::{filter,album,smart_albums}_read(s)_develop say whether a view can
change with a develop edit (through Album rules, loops ended by a seen-set),
and Catalog::view_revision gives the key such a view caches on. Rule fields
are classified by an allow-list, so a new field counts as reading develop
state until it is classified on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chad Elliott
2026-10-11 17:16:00 -05:00
co-authored by Claude Opus 5.5
parent 76b5377868
commit f80c3778bd
4 changed files with 288 additions and 2 deletions
+1 -1
View File
@@ -454,7 +454,7 @@ impl Journal {
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
}
}
catalog.revision = 0;
catalog.set_revision(0);
Ok((j, catalog, report))
}
+11
View File
@@ -21,6 +21,7 @@ pub mod local;
pub mod lock;
pub mod model;
pub mod query;
pub mod revision;
pub mod rules;
pub mod safe_file;
pub mod stacks;
@@ -38,6 +39,7 @@ pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
pub use revision::ViewRevision;
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
@@ -275,6 +277,11 @@ pub struct Catalog {
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Increments on every applied op that changes more than photos' develop state
/// ([`Op::is_develop_only`]): what views that don't read develop state key on (see
/// [`revision`]), so a slider drag doesn't work them out again on every tick.
#[serde(skip)]
pub library_revision: u64,
}
impl Catalog {
@@ -479,8 +486,12 @@ impl Catalog {
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
let develop_only = op.is_develop_only();
let inv = self.apply_inner(op)?;
self.revision += 1;
if !develop_only {
self.library_revision += 1;
}
Ok(inv)
}
+275
View File
@@ -0,0 +1,275 @@
//! What changed, for caches: [`Catalog::revision`] counts every applied op, and
//! [`Catalog::library_revision`] every op except those that only change photos' develop state
//! ([`Op::is_develop_only`]: settings, edit time, History, snapshots). A slider drag applies a
//! develop op per tick; views that don't read develop state (most filters and sorts, the sidebar's
//! counts and trees) key on the library revision and aren't worked out again on every tick.
//!
//! Some views *do* read develop state: "edited" filters and rules, Edit Date, Cropped, Treatment
//! and the size rules (crop and orientation change a photo's shown size). Those key on the full
//! revision ([`Catalog::view_revision`] with `reads_develop`), through the smart albums a filter
//! or rule tests too.
use std::collections::HashSet;
use crate::{AlbumId, Catalog, Filter, Op, Rule, RuleSet, Sort, SortKey};
/// What a cached view of the catalog was worked out from: the library revision, and the full
/// revision as well when the view reads develop state. Equal values mean the view is unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ViewRevision {
pub library: u64,
/// [`Catalog::revision`] for views that read develop state; `None` for the others.
pub all: Option<u64>,
}
/// Smart-album rule fields whose matches don't depend on a photo's develop state. Every other
/// field (including one added later and not listed here) counts as reading it, so a forgotten
/// field costs a query per slider tick, never a stale view.
const FIELDS_WITHOUT_DEVELOP: &[&str] = &[
"rating",
"flag",
"label",
"text",
// an album rule reads what that album holds: see `Catalog::filter_reads_develop`
"album",
"virtualCopy",
"copyName",
"stacked",
"fileName",
"extension",
"filePath",
"kind",
"format",
"duration",
"captureDate",
"importDate",
"keywords",
"keywordCount",
"person",
"personCount",
"title",
"caption",
"altText",
"creator",
"copyright",
"copyrightStatus",
"camera",
"lens",
"focalLength",
"aperture",
"shutterSpeed",
"iso",
"location",
"city",
"state",
"country",
"hasGps",
"sharpness",
"bestOfGroup",
];
/// Whether a rule on `field` can match differently after a develop edit (Has Edits, Edit Date,
/// Cropped, Treatment, and the sizes a crop or rotation changes).
pub fn field_reads_develop(field: &str) -> bool {
!FIELDS_WITHOUT_DEVELOP.contains(&field)
}
impl Op {
/// Whether the op only changes photos' develop state: their settings and edit time, History
/// and snapshots. Such ops bump [`Catalog::revision`] but not [`Catalog::library_revision`].
pub fn is_develop_only(&self) -> bool {
let mut next = vec![self];
while let Some(op) = next.pop() {
match op {
Op::SetDevelop { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetVersions { .. } => {}
Op::Batch { ops } => next.extend(ops),
_ => return false,
}
}
true
}
}
impl RuleSet {
/// Whether any of the rules (nested groups included) tests develop state itself; the albums
/// an Album rule tests are [`Catalog::filter_reads_develop`]'s concern.
pub fn reads_develop(&self) -> bool {
let mut next = vec![self];
while let Some(set) = next.pop() {
for rule in &set.rules {
match rule {
Rule::Group { group } => next.push(group),
Rule::Field { field, .. } if field_reads_develop(field) => return true,
Rule::Field { .. } => {}
}
}
}
false
}
}
impl Filter {
/// Whether the filter itself tests develop state: Edited, an `edited:` search token or a rule
/// that does. The albums it tests are [`Catalog::filter_reads_develop`]'s concern.
pub fn reads_develop(&self) -> bool {
self.edited.is_some()
|| self.text.split_whitespace().any(|t| t.to_lowercase().split_once(':').is_some_and(|(field, _)| field == "edited"))
|| self.rule_set.as_ref().is_some_and(RuleSet::reads_develop)
}
}
impl Sort {
/// Whether the order depends on develop state (Edit Date: every edit moves the photo).
pub fn reads_develop(&self) -> bool {
self.key == SortKey::EditDate
}
}
impl Catalog {
/// Whether which photos `filter` lets through can change with a develop edit: the filter's
/// own fields and rules, and those of every smart album it tests, directly or through other
/// smart albums (loops in saved rules end the search).
pub fn filter_reads_develop(&self, filter: &Filter) -> bool {
if filter.reads_develop() {
return true;
}
self.albums_read_develop(filter.albums_tested())
}
/// Whether what album `id` holds can change with a develop edit (a smart album whose rules,
/// or those of the smart albums they test, read develop state). Plain albums never do.
pub fn album_reads_develop(&self, id: AlbumId) -> bool {
self.albums_read_develop(vec![id])
}
/// Whether any smart album's contents can change with a develop edit (the sidebar's counts).
pub fn smart_albums_read_develop(&self) -> bool {
self.albums_read_develop(self.albums().filter(|a| a.is_smart()).map(|a| a.id).collect())
}
fn albums_read_develop(&self, mut next: Vec<AlbumId>) -> bool {
let mut seen: HashSet<AlbumId> = HashSet::new();
while let Some(id) = next.pop() {
if !seen.insert(id) {
continue;
}
let Some(rules) = self.album(id).and_then(|a| a.smart.as_deref()) else { continue };
if rules.reads_develop() {
return true;
}
next.extend(rules.albums_tested());
}
false
}
/// The revision a cached view keys on: the library revision, plus the full revision when the
/// view reads develop state (see the module docs).
pub fn view_revision(&self, reads_develop: bool) -> ViewRevision {
ViewRevision { library: self.library_revision, all: reads_develop.then_some(self.revision) }
}
/// Count revisions from `at` (both of them), as a library just loaded does.
pub fn set_revision(&mut self, at: u64) {
self.revision = at;
self.library_revision = at;
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use super::*;
use crate::{Album, Photo, PhotoId, Source};
fn photo(id: u64) -> Photo {
Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("IMG_{id}.jpg"), "JPEG", 6000, 4000, "2026-01-01T00:00:00")
}
fn rules(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
fn smart(id: u64, rules: RuleSet) -> Op {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
Op::AddAlbum { album: a }
}
#[test]
fn develop_ops_bump_only_the_full_revision() {
let mut c = Catalog::new();
c.apply(Op::AddPhoto { photo: Box::new(photo(1)) }).unwrap();
let (rev, lib) = (c.revision, c.library_revision);
let mut s = (*c.photo(PhotoId(1)).unwrap().develop).clone();
s.light.exposure = 1.0;
let set =
Op::SetDevelop { id: PhotoId(1), settings: Arc::new(s.clone()), label: "Exposure".into(), edited: Some("2026-02-01T00:00:00".into()) };
let step = Op::PushHistory { id: PhotoId(1), step: crate::HistoryStep { label: "Exposure".into(), settings: Arc::new(s) } };
c.apply(Op::Batch { ops: vec![set, step] }).unwrap();
assert_eq!((c.revision, c.library_revision), (rev + 1, lib), "a develop edit leaves the library revision");
c.apply(Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
assert_eq!((c.revision, c.library_revision), (rev + 2, lib + 1));
// a batch with any library op is a library change
let mixed =
Op::Batch { ops: vec![Op::SetHistory { id: PhotoId(1), history: Vec::new() }, Op::SetFlag { id: PhotoId(1), flag: crate::Flag::Pick }] };
c.apply(mixed).unwrap();
assert_eq!(c.library_revision, lib + 2);
// a failed op changes neither
assert!(c.apply(Op::SetRating { id: PhotoId(9), rating: 3 }).is_err());
assert_eq!((c.revision, c.library_revision), (rev + 3, lib + 2));
}
#[test]
fn filters_sorts_and_rules_that_read_develop_state() {
assert!(!Filter::default().reads_develop());
assert!(!Filter { text: "harbour rating:3".into(), rating: 2, keyword: Some("travel".into()), ..Default::default() }.reads_develop());
assert!(Filter { edited: Some(false), ..Default::default() }.reads_develop());
assert!(Filter { text: "harbour Edited:yes".into(), ..Default::default() }.reads_develop());
assert!(Sort { key: SortKey::EditDate, ..Default::default() }.reads_develop());
assert!(!Sort { key: SortKey::FileName, ..Default::default() }.reads_develop());
for field in ["edited", "cropped", "treatment", "editDate", "longEdge", "shortEdge", "aspect", "megapixels"] {
let nested = rules(
json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"group": {"match": "any", "rules": [{"field": field, "op": "is", "value": true}]}}]}),
);
assert!(nested.reads_develop(), "{field}");
}
assert!(
!rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"field": "keywords", "op": "contains", "value": "x"}]}))
.reads_develop()
);
}
/// Every rule field is either known not to read develop state or counts as reading it: a
/// new field must be put on one side on purpose.
#[test]
fn every_rule_field_is_classified() {
let reads: Vec<&str> = crate::rules::FIELDS.iter().map(|f| f.0).filter(|f| field_reads_develop(f)).collect();
assert_eq!(reads, ["editDate", "longEdge", "shortEdge", "aspect", "megapixels", "edited", "cropped", "treatment"]);
for f in FIELDS_WITHOUT_DEVELOP {
assert!(crate::rules::FIELDS.iter().any(|x| x.0 == *f), "{f} is not a rule field");
}
}
#[test]
fn smart_albums_read_develop_through_the_albums_they_test() {
let mut c = Catalog::new();
c.apply(smart(1, rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 3}]})))).unwrap();
c.apply(smart(2, rules(json!({"rules": [{"field": "edited", "op": "is", "value": true}]})))).unwrap();
// 3 tests 2 (which reads develop); 4 and 5 test each other (a loop) and 1
c.apply(smart(3, rules(json!({"rules": [{"field": "album", "op": "is", "value": 2}]})))).unwrap();
c.apply(smart(4, rules(json!({"rules": [{"field": "album", "op": "is", "value": 5}, {"field": "album", "op": "is", "value": 1}]})))).unwrap();
c.apply(smart(5, rules(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})))).unwrap();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(6), "Plain") }).unwrap();
let reads = |id: u64| c.album_reads_develop(AlbumId(id));
assert_eq!([1, 2, 3, 4, 5, 6, 7].map(reads), [false, true, true, false, false, false, false]);
assert!(c.filter_reads_develop(&Filter { album: Some(AlbumId(3)), ..Default::default() }));
assert!(!c.filter_reads_develop(&Filter { album: Some(AlbumId(4)), ..Default::default() }));
assert!(c.smart_albums_read_develop());
c.apply(Op::RemoveAlbum { id: AlbumId(2) }).unwrap();
c.apply(Op::RemoveAlbum { id: AlbumId(3) }).unwrap();
assert!(!c.smart_albums_read_develop());
}
}
+1 -1
View File
@@ -370,7 +370,7 @@ impl Session {
// in this process its own range: otherwise an empty library opened after one that was
// never edited this session would look unchanged and show the old library's numbers.
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
catalog.set_revision(LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32);
self.catalog = catalog;
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {