fix(images): fence the capture-date backfill on the file it read (#1201) (#1205)

Stable twin of #1204.

The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id, so a replaced row matches zero
rows and is skipped. The candidate query already selects both columns, so no
query change. Knex renders a null value in the object form as `is null` on both
the pg and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job.

The card shows the count only when it is non-zero, and states what is known —
changed by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage: en, de, fr, sl,
with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code on this
branch.
This commit is contained in:
Paul Nothaft
2026-08-27 08:44:32 +02:00
committed by GitHub
parent 292dd4fa09
commit 74ff236b51
7 changed files with 73 additions and 5 deletions
@@ -689,6 +689,23 @@ export const StatusTab: React.FC<StatusTabProps> = ({
failed: captureDateStatus.lastResult.failed,
defaultValue: 'Last run: {{success}} updated, {{noExif}} with no date found, {{failed}} unreachable',
})}
{/* Only when it happened. Without it the three numbers above
silently stop adding up to the count the run started with: a
photo that was replaced, renamed or dated by someone else
mid-run is read but not written.
Deliberately says "not updated" and not "will be retried":
one of the two ways to land here is another writer having
filled captured_at, and that photo is finished, not backlog.
The Missing Capture Date figure above is what says whether
anything is actually left to do. */}
{Number(captureDateStatus.lastResult.skipped) > 0 && (
<span className="block text-amber-600 dark:text-amber-400 mt-1">
{t('settings.captureDates.skipped', {
count: captureDateStatus.lastResult.skipped,
defaultValue: '{{count}} photo(s) were changed by something else while the run was reading them and were not updated.',
})}
</span>
)}
</p>
)}