LR-IMP-ADD-DIALOG: an import looks up the sidecars of a batch's photos several at a time

Where the folder listing can't vouch for a name (non-ASCII names, a listing that is gone), each photo's sidecar names are asked of the disk, nine per photo, one photo after another. The batch's lookups now run 16 at a time; the loop that plans the photos takes each one's answer in order.

They use the same `at_once` as the probe checks, so the latency stand-in measured with the previous commit applies to them as well (16 at a time against one at a time); the lookups themselves were not timed on a share for this version.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-28 07:59:03 -04:00
co-authored by Claude Sonnet 5.5
parent 6c19862876
commit d402fcc4b6
4 changed files with 123 additions and 29 deletions
+101 -23
View File
@@ -245,7 +245,10 @@ pub(crate) enum Seen {
Unchecked,
/// Not there, or not readable.
Gone,
At { is_file: bool, len: u64 },
At {
is_file: bool,
len: u64,
},
}
/// [`Seen`] of `path` from the file system.
@@ -256,57 +259,74 @@ fn stat_file(path: &str) -> Seen {
}
}
/// Names asked about at once: a stat on a network share is a round trip (50 ms and more under load)
/// that waits for nothing but the server, so a few more than the readers' threads.
/// Files asked about at once: a question to a network share (a stat, a name looked up) is a round
/// trip (50 ms and more under load) that waits for nothing but the server, so a few more than the
/// readers' threads.
#[cfg(not(target_arch = "wasm32"))]
const STAT_THREADS: usize = 16;
const AT_ONCE: usize = 16;
/// Ask `stat` about each of `paths`, several at once (one at a time, a folder of thousands of photos
/// on a network share waited minutes for its stats alone). The answers come in the order of `paths`;
/// the ones not reached once `cancel` is set are [`Seen::Unchecked`].
pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec<Seen> {
/// Run `ask` on each of `items`, several at once (one at a time, a folder of thousands of photos on
/// a network share waited minutes for its questions alone). The answers come in the order of
/// `items`; `None` for an item not reached once `cancel` is set (or whose asker died).
fn at_once<T: Sync, R: Send>(items: &[T], cancel: &std::sync::atomic::AtomicBool, ask: impl Fn(&T) -> R + Sync) -> Vec<Option<R>> {
use std::sync::atomic::Ordering::Relaxed;
let mut seen = vec![Seen::Unchecked; paths.len()];
let mut answers: Vec<Option<R>> = items.iter().map(|_| None).collect();
#[cfg(not(target_arch = "wasm32"))]
{
let n = STAT_THREADS.min(paths.len());
let n = AT_ONCE.min(items.len());
if n > 1 {
let next = std::sync::atomic::AtomicUsize::new(0);
let answers: Vec<Vec<(usize, Seen)>> = std::thread::scope(|sc| {
let readers: Vec<_> = (0..n)
let mine: Vec<Vec<(usize, R)>> = std::thread::scope(|sc| {
let askers: Vec<_> = (0..n)
.map(|_| {
sc.spawn(|| {
let mut mine = Vec::new();
loop {
let i = next.fetch_add(1, Relaxed);
let Some(path) = paths.get(i) else { break };
let Some(item) = items.get(i) else { break };
if cancel.load(Relaxed) {
break;
}
mine.push((i, stat(path)));
mine.push((i, ask(item)));
}
mine
})
})
.collect();
// (a reader that died leaves its files unchecked)
readers.into_iter().map(|r| r.join().unwrap_or_default()).collect()
askers.into_iter().map(|a| a.join().unwrap_or_default()).collect()
});
for (i, s) in answers.into_iter().flatten() {
if let Some(slot) = seen.get_mut(i) {
*slot = s;
for (i, r) in mine.into_iter().flatten() {
if let Some(slot) = answers.get_mut(i) {
*slot = Some(r);
}
}
return seen;
return answers;
}
}
for (slot, path) in seen.iter_mut().zip(paths) {
for (slot, item) in answers.iter_mut().zip(items) {
if cancel.load(Relaxed) {
break;
}
*slot = stat(path);
*slot = Some(ask(item));
}
seen
answers
}
/// Ask `stat` about each of `paths`, several at once ([`at_once`]); the ones not reached once
/// `cancel` is set are [`Seen::Unchecked`].
pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec<Seen> {
at_once(paths, cancel, |p| stat(p)).into_iter().map(|s| s.unwrap_or(Seen::Unchecked)).collect()
}
/// The sidecar of each of `files` (`find` says which: a few names looked up on the disk for each,
/// a round trip apiece on a network share), several files at once ([`at_once`]); `None` for a
/// file with no sidecar or not reached once `cancel` is set.
pub(super) fn sidecars_of(
files: &[&str],
cancel: &std::sync::atomic::AtomicBool,
find: impl Fn(&str) -> Option<PathBuf> + Sync,
) -> Vec<Option<PathBuf>> {
at_once(files, cancel, |f| find(f)).into_iter().map(Option::flatten).collect()
}
/// The probes in `cache` of `files` that are still true: the file is looked at ([`stat_all`]) and
@@ -539,4 +559,62 @@ mod prepared_tests {
assert_eq!(prepared.items.len(), 4);
let _ = std::fs::remove_dir_all(&dir);
}
/// Given photos on a network share, when an import looks for their sidecars, then it asks about
/// several photos at once: one photo after another, nine names each, they were 77 % of the
/// import's preparation (95 % where a folder listing could not vouch for the names).
#[test]
fn sidecars_are_looked_up_for_several_photos_at_once() {
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
let (active, together) = (AtomicUsize::new(0), AtomicUsize::new(0));
let names: Vec<String> = (0..6).map(|i| format!("photo-{i}.jpg")).collect();
let files: Vec<&str> = names.iter().map(String::as_str).collect();
let found = sidecars_of(&files, &std::sync::atomic::AtomicBool::new(false), |f| {
active.fetch_add(1, Relaxed);
let end = std::time::Instant::now() + std::time::Duration::from_secs(1);
while active.load(Relaxed) < 2 && std::time::Instant::now() < end {
std::thread::yield_now();
}
together.fetch_max(active.load(Relaxed), Relaxed);
active.fetch_sub(1, Relaxed);
f.ends_with("-3.jpg").then(|| PathBuf::from(f).with_extension("xmp"))
});
assert!(together.load(Relaxed) >= 2, "the photos were looked up one at a time");
let expect: Vec<Option<PathBuf>> = (0..6).map(|i| (i == 3).then(|| PathBuf::from("photo-3.xmp"))).collect();
assert_eq!(found, expect, "each sidecar stays with its photo");
}
/// The sidecars found for a batch go to the photos they belong to, whichever have one, and a
/// photo that failed to probe takes none of the others'.
#[test]
fn a_batchs_sidecars_go_to_their_own_photos() {
let dir = std::env::temp_dir().join(format!("lightcraft-sidecars-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="4"/></rdf:RDF></x:xmpmeta>"#;
let files: Vec<String> = ["a.jpg", "broken.jpg", "c.jpg", "d.jpg"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect();
for f in &files {
std::fs::write(f, b"x").unwrap();
}
std::fs::write(dir.join("c.xmp"), xmp).unwrap();
let mut s = Session::new();
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
job.probe = Some(std::sync::Arc::new(|p: &str| {
if p.ends_with("broken.jpg") {
Err("unreadable".into())
} else {
Ok(ProbeInfo { format: "JPEG".into(), file_size: 1, ..ProbeInfo::default() })
}
}));
let prepared = job.prepare_files(files, &std::sync::atomic::AtomicBool::new(false));
let rating = |name: &str| {
prepared.items.iter().find_map(|i| match i {
PreparedItem::Ready(r) if r.path.ends_with(name) => Some(r.sidecar.as_ref().and_then(|sc| sc.rating)),
_ => None,
})
};
assert_eq!((rating("a.jpg"), rating("c.jpg"), rating("d.jpg")), (Some(None), Some(Some(4)), Some(None)));
assert!(prepared.items.iter().any(|i| matches!(i, PreparedItem::Failed(p, _) if p.ends_with("broken.jpg"))));
let _ = std::fs::remove_dir_all(&dir);
}
}
+17 -3
View File
@@ -289,6 +289,20 @@ impl ImportJob {
library.as_ref(),
todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))),
);
// The sidecar of every file that was probed, looked up for the batch at once: each is a few
// names asked of the disk, which on a network share made a folder of thousands of photos wait
// a round trip per name, one name after another.
let asked: Vec<&str> = todo.iter().zip(&probed).filter(|(_, p)| p.is_ok()).map(|(f, _)| f.as_str()).collect();
// (a file whose content the library has is skipped below as a duplicate: it asks the disk nothing)
let known: std::collections::HashSet<&str> = todo
.iter()
.zip(&probed)
.filter(|(_, p)| !opts.local && p.as_ref().is_ok_and(|i| i.content_hash.as_ref().is_some_and(|h| self.by_hash.contains_key(h))))
.map(|(f, _)| f.as_str())
.collect();
let mut found =
sidecars_of(&asked, cancel, |f| if known.contains(f) { None } else { crate::sidecar::find_sidecar_for_import(&stems, self.naming, f) })
.into_iter();
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
// copies or moves — the slow part, reading and writing every byte and verifying it — are
// collected and run on several threads (issue #367), then their outcomes take their places.
@@ -306,6 +320,8 @@ impl ImportJob {
continue;
}
};
// (this file's place in the lookups above: files that failed to probe have none)
let found_sidecar = found.next().flatten();
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
if !opts.local
&& let Some(h) = &info.content_hash
@@ -327,9 +343,7 @@ impl ImportJob {
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let packet = found_sidecar.and_then(|f| std::fs::read_to_string(f).ok()).or_else(|| info.xmp.clone().filter(|_| raw));
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
Ok(sc) => Some(sc),
+2 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left 16 files at a time; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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
@@ -959,4 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
| 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was 16 files at a time instead of one after another; on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) |
| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was up to 16 files at a time (an import batch's files, at most) instead of one after another, and looks up a batch's sidecars the same way (`import::sidecars_of`); on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) |
+3 -1
View File
@@ -5,8 +5,10 @@
### Importing a folder from a network share waits less
- After the import review, the import used to check that each photo was still as the review had seen it by asking the
disk about one photo after another, so on a network share every photo waited for a round trip to the server. It now
asks about 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review
asks about up to 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review
is unchanged.
- Looking for each photo's `.xmp` sidecar, which asks the disk several names for every photo, also goes up to 16
photos at a time.
### Relative-date filters and smart albums follow the clock
- A current view or source count using “in the last” rules now refreshes when the session clock moves into a new