From 7b2d7b25ea569ab477e0dec9a6e1e9aa013286f8 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 18 Oct 2026 18:16:20 +0200 Subject: [PATCH] LR-BEHAV-BG-TASKS: an older Reload can't overwrite a newer one; background Reload auto-writes sidecars Review of #1998. Each reload plan takes a ticket; apply skips a photo a later-started reload has already been applied to, so an older read that finishes last no longer restores the previous dimensions and content hash. The app's background completion now commits through Session::execute_fn, so the XMP sidecar auto-write (and the save) happen as for the command. Regression tests: an_older_reload_does_not_replace_a_newer_one, a_background_reload_writes_the_sidecar_with_auto_write_on. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/reload.rs | 33 +++++++++--- crates/engine/src/lib.rs | 3 ++ crates/ui-egui/src/reload.rs | 10 ++-- crates/ui-egui/src/tests_recheck.rs | 80 ++++++++++++++++++++++++++++- docs/background-tasks.md | 2 +- docs/parity-checklist.md | 4 +- 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs index 9fc0a590f..0c1cb964c 100644 --- a/crates/engine/src/cmd/reload.rs +++ b/crates/engine/src/cmd/reload.rs @@ -3,6 +3,10 @@ //! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads //! their files without the session (cancellable, with progress), and [`apply`] commits what came //! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. +//! +//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so +//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later +//! has already been applied to it: an older read never replaces a newer one. use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -18,14 +22,28 @@ use crate::{Result, Session}; pub struct Item { pub id: PhotoId, pub path: String, + /// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]). + pub ticket: u64, } -/// The photos `p` targets (`ids`, else the selection) that have a file. No file-system calls. -pub fn plan(s: &Session, p: &Value) -> Result> { - Ok(s.targets(p)? +/// Which reload each photo last took its file information from, so a reload started earlier but +/// finishing later doesn't put back what its file was before (session memory, not saved). +#[derive(Debug, Default)] +pub struct Tickets { + last: u64, + applied: std::collections::BTreeMap, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No +/// file-system calls. +pub fn plan(s: &mut Session, p: &Value) -> Result> { + let ids = s.targets(p)?; + s.reload_tickets.last = s.reload_tickets.last.saturating_add(1); + let ticket = s.reload_tickets.last; + Ok(ids .into_iter() .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { - Some(Source::File { path }) => Some(Item { id, path: path.clone() }), + Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }), _ => None, }) .collect()) @@ -66,16 +84,19 @@ pub fn probe( /// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual /// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed -/// or relinked since it was listed is skipped. Returns the photos that changed. +/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns +/// the photos that changed. pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { let mut ops = Vec::new(); let mut reloaded = Vec::new(); for (item, info) in items.into_iter().zip(results) { let Ok(info) = info else { continue }; let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); - if !current { + let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket); + if !current || superseded { continue; } + s.reload_tickets.applied.insert(item.id, item.ticket); crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); } for id in &reloaded { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..ab6c048f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -240,6 +240,8 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]). + pub(crate) reload_tickets: cmd::reload::Tickets, /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by /// [`Session::retry_unsaved_sidecars`] once their file is reachable. pub(crate) unsaved_sidecars: std::collections::BTreeSet, @@ -434,6 +436,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + reload_tickets: Default::default(), unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs index 32823cf16..94c9510ab 100644 --- a/crates/ui-egui/src/reload.rs +++ b/crates/ui-egui/src/reload.rs @@ -1,7 +1,8 @@ //! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a //! second or more, and a selection of them froze the window), with a row in the activity stack -//! that counts the files and can stop. What came of it is applied back here, as one undo step, -//! unless another library was opened meanwhile. Agents get the same with `wait: true`, which runs +//! that counts the files and can stop. What came of it is applied back here through the session's +//! command wrapper (one undo step, saved, sidecars auto-written), unless another library was opened +//! meanwhile or a reload started later has already been applied to the photo. Agents get the same with `wait: true`, which runs //! the engine's command in place. use std::sync::Arc; @@ -22,7 +23,7 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option items, Err(e) => return Some(Err(format!("photo.reload: {e}"))), }; @@ -47,7 +48,8 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (LR-BEHAV-BG-TASKS: Reload from Disk in the background) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -797,7 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; status unchanged (✅) | +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) |