Files
ImageApi/src/backfill.rs
T
Cameron CordesandClaude Opus 5.5 b6deca2a52 Tracing: DAO layer + handler spans on tracing, drop all Context params (#141)
trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.

The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.

Background-job raw spans (thumbnails, video actors) are left for #142.

tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00

1703 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Per-tick drains the watcher runs alongside ingest.
//!
//! These passes were previously inlined in `main.rs`; they exist because
//! a quick scan only walks recently-modified files, so any backlog of
//! rows missing a `content_hash` / `date_taken` / face detection
//! wouldn't otherwise drain except during the once-an-hour full scan.
//! Each function is bounded per call by a `*_PER_TICK` env-var cap.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Datelike, NaiveDate, TimeZone};
use lazy_static::lazy_static;
use log::{debug, info, warn};
use prometheus::IntGauge;
use crate::camera_tz;
use crate::content_hash;
use crate::database::models::PrecomputedReel;
use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate};
use crate::date_resolver;
use crate::face_watch;
use crate::faces;
use crate::file_types;
use crate::libraries;
use crate::tags;
/// Compute and persist content_hash for image_exif rows where it's NULL.
///
/// Bounded per call by `FACE_HASH_BACKFILL_MAX_PER_TICK` (default 2000)
/// so a watcher tick on a large legacy library doesn't block for hours
/// blake3-ing every photo at once. Subsequent scans pick up the rest.
/// For 50k+ libraries the dedicated `cargo run --bin backfill_hashes`
/// is still faster (it doesn't fight a watcher loop for the DAO mutex).
///
/// Drains unhashed image_exif rows by querying them directly, independent
/// of the filesystem walk. Quick scans only walk recently-modified files,
/// so a backlog of pre-existing unhashed rows never enters
/// `process_new_files`'s candidate set — left alone, it would only drain
/// on full scans (default once an hour). Calling this every tick keeps
/// the face-detection backlog moving regardless.
///
/// Returns the number of rows successfully backfilled this pass.
pub fn backfill_unhashed_backlog(
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
let cap: i64 = dotenv::var("FACE_HASH_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(2000);
// Fetch up to cap+1 rows so we can tell "more remain" without a
// separate count query. Across libraries — there's no per-library
// filter on get_rows_missing_hash today — but we only ever update
// rows whose library_id matches the caller's library, so other
// libraries' rows just get skipped here and picked up on the next
// library's tick. Negligible cost given the cap.
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_missing_hash(cap + 1).unwrap_or_default()
};
if rows.is_empty() {
return 0;
}
let more_than_cap = rows.len() as i64 > cap;
let base_path = std::path::Path::new(&library.root_path);
let mut backfilled = 0usize;
let mut errors = 0usize;
let mut skipped_other_lib = 0usize;
for (lib_id, rel_path) in rows.iter().take(cap as usize) {
if *lib_id != library.id {
skipped_other_lib += 1;
continue;
}
let abs = base_path.join(rel_path);
if !abs.exists() {
// File walked away — the watcher's reconciliation pass will
// remove the orphan exif row eventually.
continue;
}
match content_hash::compute(&abs) {
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) =
dao.backfill_content_hash(library.id, rel_path, &id.content_hash, id.size_bytes)
{
warn!(
"face_watch: backfill_content_hash failed for {}: {:?}",
rel_path, e
);
errors += 1;
} else {
backfilled += 1;
}
}
Err(e) => {
debug!(
"face_watch: hash compute failed for {} ({:?})",
abs.display(),
e
);
errors += 1;
}
}
}
if backfilled > 0 || errors > 0 || more_than_cap {
info!(
"face_watch: backfill pass for library '{}': hashed {} ({} error(s), {} skipped to other libraries; {} cap, more_remain={})",
library.name, backfilled, errors, skipped_other_lib, cap, more_than_cap
);
}
backfilled
}
/// `DATE_BACKFILL_MAX_PER_TICK` (default 500): the per-tick cap shared by
/// both date drains.
fn date_backfill_cap() -> i64 {
dotenv::var("DATE_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(500)
}
/// Drain image_exif rows whose `date_taken` was never resolved or was
/// resolved by the weakest fallback (`fs_time`). Runs the canonical-date
/// waterfall — exiftool batch (one subprocess for the whole tick's
/// rows) → filename regex → earliest_fs_time — and persists each
/// resolution with its source tag. Capped per tick by
/// `DATE_BACKFILL_MAX_PER_TICK` (default 500) so a 14k-row library
/// drains over a few quick-scan ticks without blocking the watcher.
///
/// kamadak-exif is intentionally skipped here: the row already has a
/// NULL date_taken because the ingest path's kamadak-exif call returned
/// nothing, and re-running it would just produce the same answer.
/// exiftool is the meaningful new attempt — it handles videos and
/// MakerNote-hosted dates kamadak can't reach.
pub fn backfill_missing_date_taken(
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
let cap = date_backfill_cap();
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_needing_date_backfill(library.id, cap + 1)
.unwrap_or_default()
};
if rows.is_empty() {
return 0;
}
let more_than_cap = rows.len() as i64 > cap;
let base_path = std::path::Path::new(&library.root_path);
// Build absolute paths and drop rows whose files no longer exist —
// the missing-file scan in library_maintenance retires deleted rows
// separately. Without this filter, NULL-date rows for missing files
// would loop through the drain forever (no source can resolve them).
let mut existing: Vec<(String, PathBuf)> = Vec::with_capacity(rows.len());
for (_, rel_path) in rows.iter().take(cap as usize) {
let abs = base_path.join(rel_path);
if abs.exists() {
existing.push((rel_path.clone(), abs));
}
}
if existing.is_empty() {
return 0;
}
// One exiftool subprocess for the whole batch; the resolver falls
// through to filename / fs_time per file when exiftool can't supply
// a date (or isn't installed at all).
let paths: Vec<PathBuf> = existing.iter().map(|(_, p)| p.clone()).collect();
let resolved = date_resolver::resolve_dates_batch(&paths, &HashMap::new());
let mut backfilled = 0usize;
let mut unresolved = 0usize;
let mut by_source: HashMap<&'static str, usize> = HashMap::new();
{
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
for (rel_path, abs) in &existing {
let Some(rd) = resolved.get(abs).copied() else {
unresolved += 1;
continue;
};
match dao.backfill_date_taken(
library.id,
rel_path,
rd.timestamp,
rd.source.as_str(),
rd.date_utc,
rd.camera_offset_minutes,
rd.tz_source.map(|t| t.as_str()),
) {
Ok(()) => {
backfilled += 1;
*by_source.entry(rd.source.as_str()).or_insert(0) += 1;
}
Err(e) => {
warn!(
"date_backfill: update failed for lib {} {}: {:?}",
library.id, rel_path, e
);
}
}
}
}
if backfilled > 0 || unresolved > 0 || more_than_cap {
info!(
"date_backfill: library '{}': resolved {} ({:?}), {} unresolved, cap={}, more_remain={}",
library.name, backfilled, by_source, unresolved, cap, more_than_cap
);
}
backfilled
}
lazy_static! {
/// Rows not yet on the true-instant date contract (`date_utc IS
/// NULL`), summed across libraries. Refreshed by the watcher every
/// tick; cutover (#120) waits on this reaching zero.
pub static ref DATE_BACKFILL_REMAINING: IntGauge = IntGauge::new(
"imageserver_date_backfill_remaining",
"Rows not yet converted to the true-instant date contract"
)
.unwrap();
}
/// Upper bound on rows the true-instant drain pages past in one tick,
/// as a multiple of the cap. Unconvertible rows (missing files) don't
/// spend the cap, but a library full of them must not turn one tick into
/// a full-table walk.
const UTC_BACKFILL_SCAN_FACTOR: i64 = 20;
/// One row's resolved new-contract triple, pending write.
struct Conversion {
row: UtcBackfillCandidate,
date_utc: i64,
camera_offset_minutes: Option<i32>,
tz_source: camera_tz::TzSource,
}
/// What one tick of [`backfill_utc_dates`] did for one library.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UtcBackfillOutcome {
/// Rows whose new-contract triple was written this tick.
pub converted: usize,
/// Rows paged past because their file is missing (didn't spend cap).
pub skipped_missing: usize,
/// Rows the waterfall couldn't convert faithfully (left for later).
pub unresolved: usize,
/// Converted rows whose display-zone calendar day moved.
pub day_changed: usize,
/// Pre-generated day reels deleted because a day they cover moved.
pub reels_invalidated: usize,
}
/// True-instant backfill drain (ADR-0001, ticket #118): convert rows
/// still on a legacy mixed convention (`date_utc IS NULL AND date_taken
/// IS NOT NULL`) to true instants by re-running the canonical waterfall —
/// one exiftool subprocess per batch, same call shape as the #117 audit
/// preview (`resolve_dates_batch` with empty priors). Writes only the
/// new-contract triple; the legacy `date_taken` / `date_taken_source`
/// pair is frozen. Capped per tick by `DATE_BACKFILL_MAX_PER_TICK`
/// (default 500), shared with [`backfill_missing_date_taken`].
///
/// Legacy `manual` rows (overrides set before #115) aren't re-resolved
/// from the file — that would discard the operator's pick. Their legacy
/// value is the wall clock the UI sent, so they convert the same way a
/// manual edit does today (`camera_tz::manual_date_resolution` in the
/// display zone).
///
/// `cursor` is the caller's per-library resume point (last row id
/// visited), carried across ticks and wrapped to 0 at the end of the
/// table. Rows that can't convert — missing files, no exiftool, no
/// faithful resolution — are left behind the cursor instead of being
/// re-collected first every tick, so they can never stall the rows after
/// them (the "spun on the same lowest-id rows" failure of the legacy
/// drain's first version).
///
/// Day-boundary invalidation: when a converted instant falls on a
/// different display-zone calendar day than the legacy value did (as the
/// legacy `/memories` SQL rendered it), the pre-generated `day` reels
/// covering either month-day are deleted so the nightly pre-gen
/// regenerates them. Daily conversation summaries are SMS-derived and
/// don't depend on photo dates, so they're not touched.
pub fn backfill_utc_dates(
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
display_tz: &str,
cursor: &mut i32,
) -> UtcBackfillOutcome {
let cap = date_backfill_cap();
backfill_utc_dates_capped(library, exif_dao, reel_dao, display_tz, cursor, cap)
}
fn backfill_utc_dates_capped(
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
display_tz: &str,
cursor: &mut i32,
cap: i64,
) -> UtcBackfillOutcome {
let mut outcome = UtcBackfillOutcome::default();
let base_path = std::path::Path::new(&library.root_path);
// The watcher only calls this for online libraries; re-check here so
// an unmounted root can never partially convert (manual rows need no
// file and would otherwise convert while the rest waited).
if !base_path.is_dir() {
return outcome;
}
let zone_name = camera_tz::display_zone_for(Some(display_tz));
let Ok(zone) = zone_name.parse::<chrono_tz::Tz>() else {
return outcome;
};
// Without exiftool, legacy EXIF/exiftool rows would fall through the
// empty-prior waterfall to fs_time and land a wrong instant. Leave
// them for a deploy that has it.
let exiftool_ok = date_resolver::exiftool_available();
// Page by id from the resume cursor until `cap` convertible rows are
// collected. Rows that can't convert this tick are paged past, not
// counted against the cap.
let mut manual: Vec<UtcBackfillCandidate> = Vec::new();
let mut filed: Vec<(UtcBackfillCandidate, PathBuf)> = Vec::new();
let mut scanned = 0i64;
'paging: loop {
let page = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_needing_utc_backfill(library.id, *cursor, cap)
.unwrap_or_default()
};
let page_len = page.len() as i64;
for row in page {
*cursor = row.id;
scanned += 1;
match row.date_taken_source.as_deref() {
Some("manual") => manual.push(row),
Some("exif") | Some("exiftool") if !exiftool_ok => outcome.unresolved += 1,
_ => {
let abs = base_path.join(&row.rel_path);
if abs.exists() {
filed.push((row, abs));
} else {
// The missing-file scan retires these rows.
outcome.skipped_missing += 1;
}
}
}
if (manual.len() + filed.len()) as i64 >= cap {
break 'paging;
}
}
if page_len < cap {
// End of the table: wrap so next tick revisits what was left
// behind (and anything that became convertible since).
*cursor = 0;
break;
}
if scanned >= cap * UTC_BACKFILL_SCAN_FACTOR {
break;
}
}
let more_remain = (manual.len() + filed.len()) as i64 >= cap;
let mut conversions: Vec<Conversion> = Vec::with_capacity(manual.len() + filed.len());
for row in manual {
match camera_tz::manual_date_resolution(Some(&zone_name), row.date_taken) {
Some((utc, offset)) => conversions.push(Conversion {
row,
date_utc: utc,
camera_offset_minutes: Some(offset),
tz_source: camera_tz::TzSource::Manual,
}),
None => outcome.unresolved += 1,
}
}
if !filed.is_empty() {
// One exiftool subprocess for the whole batch — byte-identical
// call shape to the #117 audit preview.
let paths: Vec<PathBuf> = filed.iter().map(|(_, p)| p.clone()).collect();
let resolved = date_resolver::resolve_dates_batch(&paths, &HashMap::new());
for (row, abs) in filed {
let legacy_had_tag = matches!(
row.date_taken_source.as_deref(),
Some("exif") | Some("exiftool")
);
match resolved.get(&abs) {
Some(rd) if !(legacy_had_tag && rd.source == date_resolver::DateSource::FsTime) => {
match (rd.date_utc, rd.tz_source) {
(Some(utc), Some(tz)) => conversions.push(Conversion {
row,
date_utc: utc,
camera_offset_minutes: rd.camera_offset_minutes,
tz_source: tz,
}),
_ => outcome.unresolved += 1,
}
}
// No result, or a row whose legacy date came from a tag
// now resolving to fs_time (exiftool failed on it): don't
// bake a filesystem time in over a real capture date.
_ => {
debug!(
"utc_backfill: no faithful resolution for lib {} {}",
library.id, row.rel_path
);
outcome.unresolved += 1;
}
}
}
}
let mut affected_days: HashSet<(u32, u32)> = HashSet::new();
{
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
for Conversion {
row,
date_utc: utc,
camera_offset_minutes: offset,
tz_source,
} in &conversions
{
match dao.backfill_date_utc(row.id, *utc, *offset, tz_source.as_str()) {
Ok(true) => {
outcome.converted += 1;
let before = display_day(zone, row.date_taken);
let after = display_day(zone, *utc);
if let (Some(before), Some(after)) = (before, after)
&& before != after
{
outcome.day_changed += 1;
affected_days.insert((before.month(), before.day()));
affected_days.insert((after.month(), after.day()));
}
}
// A manual edit / ingest raced us and already wrote it.
Ok(false) => {}
Err(e) => warn!(
"utc_backfill: update failed for lib {} {}: {:?}",
library.id, row.rel_path, e
),
}
}
}
if !affected_days.is_empty() {
outcome.reels_invalidated = invalidate_day_reels(library, reel_dao, zone, &affected_days);
}
if outcome.converted > 0 || outcome.unresolved > 0 || more_remain {
info!(
"utc_backfill: library '{}': {:?}, cap={}, more_remain={}",
library.name, outcome, cap, more_remain
);
}
outcome
}
/// Calendar day of a unix instant in the display zone. The legacy value
/// goes through here too: the legacy `/memories` SQL rendered
/// `date_taken` as an instant in the client's zone, so that is the day
/// the row's derived day data was built from.
fn display_day(zone: chrono_tz::Tz, epoch: i64) -> Option<NaiveDate> {
zone.timestamp_opt(epoch, 0)
.single()
.map(|dt| dt.date_naive())
}
/// The month-day an "on this day" reel covers: the date it was generated
/// on, in the zone offset it was generated with. The pre-gen fallback
/// `reels::fixed_tz_offset` (no prefs row, no env override) stores
/// `local_minus_utc()` — seconds, not minutes — so an out-of-range offset
/// falls back to the display zone.
fn reel_covered_day(reel: &PrecomputedReel, zone: chrono_tz::Tz) -> Option<NaiveDate> {
if reel.tz_offset_minutes.abs() <= 14 * 60 {
DateTime::from_timestamp(reel.generated_at + reel.tz_offset_minutes as i64 * 60, 0)
.map(|dt| dt.date_naive())
} else {
display_day(zone, reel.generated_at)
}
}
/// Delete the pre-generated `day` reels (this library's and the
/// all-libraries one) covering any of `affected_days`, plus their files.
/// With the ledger row gone the nightly pre-gen's freshness gate fails
/// and it renders a new one on its normal schedule. Returns rows deleted.
fn invalidate_day_reels(
library: &libraries::Library,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
zone: chrono_tz::Tz,
affected_days: &HashSet<(u32, u32)>,
) -> usize {
let keys = vec!["all".to_string(), library.id.to_string()];
let mut dao = reel_dao.lock().expect("Unable to lock PrecomputedReelDao");
let reels = match dao.list_for_span("day", &keys) {
Ok(r) => r,
Err(e) => {
warn!("utc_backfill: listing day reels failed: {:?}", e);
return 0;
}
};
let stale: Vec<&PrecomputedReel> = reels
.iter()
.filter(|r| {
reel_covered_day(r, zone).is_some_and(|d| affected_days.contains(&(d.month(), d.day())))
})
.collect();
if stale.is_empty() {
return 0;
}
let ids: Vec<i32> = stale.iter().map(|r| r.id).collect();
match dao.delete_ids(&ids) {
Ok(n) => {
// Unlink too, so the on-demand cache can't serve the stale
// render under the same key.
for r in &stale {
let mp4 = std::path::Path::new(&r.output_path);
let _ = std::fs::remove_file(mp4);
let _ = std::fs::remove_file(mp4.with_extension("json"));
}
info!(
"utc_backfill: invalidated {} day reel(s) for library '{}' (re-dated days {:?})",
n, library.name, affected_days
);
n
}
Err(e) => {
warn!("utc_backfill: deleting stale day reels failed: {:?}", e);
0
}
}
}
/// Refresh the progress surface for one online library: store its
/// remaining count in `remaining` and set [`DATE_BACKFILL_REMAINING`] to
/// the sum. Offline libraries keep their last-known count (the watcher
/// doesn't call this for them), so a flapping share doesn't make the
/// gauge dip.
pub fn refresh_utc_backfill_progress(
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
remaining: &mut HashMap<i32, i64>,
) {
let count = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.count_rows_needing_utc_backfill(library.id)
};
if let Ok(n) = count {
remaining.insert(library.id, n);
DATE_BACKFILL_REMAINING.set(remaining.values().sum());
}
}
/// Per-tick face-detection drain. Pulls a capped batch of hashed-but-
/// unscanned image_exif rows directly via the FaceDao anti-join and
/// hands them to the existing detection pass. Runs on every tick (not
/// just full scans) so the backlog moves at quick-scan cadence.
/// Per-tick CLIP encoding drain. Mirrors `process_face_backlog`: pull
/// up to `CLIP_BACKLOG_MAX_PER_TICK` candidates with a known
/// `content_hash` but no `clip_embedding`, hand them to
/// `clip_watch::run_clip_encoding_pass` for parallel fan-out, and let
/// that module write the result back via `backfill_clip_embedding`.
///
/// Idempotent — a row stays in the candidate set until its embedding
/// lands, so a transient failure (Apollo unreachable, CUDA OOM) just
/// defers to the next tick. Permanent failures (un-decodable bytes)
/// retry every tick at this point; future Branch may add a status
/// column like face_detections has.
pub fn process_clip_backlog(
library: &libraries::Library,
clip_client: &crate::ai::clip_client::ClipClient,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
excluded_dirs: &[String],
) {
if !clip_client.is_enabled() {
return;
}
let cap: i64 = dotenv::var("CLIP_BACKLOG_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(32);
let rows: Vec<(String, String)> = {
let mut dao = exif_dao.lock().expect("exif dao");
match dao.list_clip_unencoded_candidates(library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
"clip_watch: list_clip_unencoded_candidates failed for library '{}': {:?}",
library.name, e
);
return;
}
}
};
if rows.is_empty() {
return;
}
info!(
"clip_watch: backlog drain — encoding {} candidate(s) for library '{}' (cap={})",
rows.len(),
library.name,
cap
);
let candidates: Vec<crate::clip_watch::ClipCandidate> = rows
.into_iter()
.map(
|(rel_path, content_hash)| crate::clip_watch::ClipCandidate {
rel_path,
content_hash,
},
)
.collect();
crate::clip_watch::run_clip_encoding_pass(
library,
excluded_dirs,
clip_client,
Arc::clone(exif_dao),
candidates,
);
}
pub fn process_face_backlog(
library: &libraries::Library,
face_client: &crate::ai::face_client::FaceClient,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
tag_dao: &Arc<Mutex<Box<dyn tags::TagDao>>>,
excluded_dirs: &[String],
) {
let cap: i64 = dotenv::var("FACE_BACKLOG_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(64);
let rows: Vec<(String, String)> = {
let mut dao = face_dao.lock().expect("face dao");
match dao.list_unscanned_candidates(library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
"face_watch: list_unscanned_candidates failed for library '{}': {:?}",
library.name, e
);
return;
}
}
};
if rows.is_empty() {
return;
}
info!(
"face_watch: backlog drain — running detection on {} candidate(s) for library '{}' (cap={})",
rows.len(),
library.name,
cap
);
let candidates: Vec<face_watch::FaceCandidate> = rows
.into_iter()
.map(|(rel_path, content_hash)| face_watch::FaceCandidate {
rel_path,
content_hash,
})
.collect();
face_watch::run_face_detection_pass(
library,
excluded_dirs,
face_client,
Arc::clone(face_dao),
Arc::clone(tag_dao),
candidates,
);
}
/// Compute content_hash for any image rows the walker just touched
/// whose stored EXIF row is still hash-less. Called from
/// `process_new_files` so freshly-ingested files don't have to wait for
/// the next standalone `backfill_unhashed_backlog` tick before face
/// detection can key on their bytes.
///
/// Cap is on **successes only**. An earlier version counted errors too,
/// so a pocket of chronically-unhashable files at the front of the
/// table (vanished mid-scan, permission denied, etc.) burned the budget
/// every tick and the rest of the backlog never advanced.
pub fn backfill_missing_content_hashes(
files: &[(PathBuf, String)],
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) {
let image_paths: Vec<String> = files
.iter()
.filter(|(p, _)| !file_types::is_video_file(p))
.map(|(_, rel)| rel.clone())
.collect();
if image_paths.is_empty() {
return;
}
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(Some(library.id), &image_paths)
.unwrap_or_default()
};
// Cheap lookup back from rel_path → absolute file_path so
// content_hash::compute can read the bytes.
let path_by_rel: HashMap<String, &PathBuf> =
files.iter().map(|(p, rel)| (rel.clone(), p)).collect();
let cap: usize = dotenv::var("FACE_HASH_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &usize| *n > 0)
.unwrap_or(2000);
// Count the unhashed backlog up front so we can surface "still needs
// backfill: N" in the log — without it, a face-scan that's stuck at
// 44% looks stalled when really it's chipping through hashes.
let unhashed_total = exif_records
.iter()
.filter(|r| r.content_hash.is_none())
.count();
let mut backfilled = 0usize;
let mut errors = 0usize;
for record in &exif_records {
if backfilled >= cap {
break;
}
if record.content_hash.is_some() {
continue;
}
let Some(file_path) = path_by_rel.get(&record.file_path) else {
// Walked file went missing between the directory scan and now;
// next tick will retry naturally.
continue;
};
match content_hash::compute(file_path) {
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.backfill_content_hash(
library.id,
&record.file_path,
&id.content_hash,
id.size_bytes,
) {
warn!(
"face_watch: backfill_content_hash failed for {}: {:?}",
record.file_path, e
);
errors += 1;
} else {
backfilled += 1;
}
}
Err(e) => {
debug!(
"face_watch: hash compute failed for {} ({:?})",
file_path.display(),
e
);
errors += 1;
}
}
}
// Always log when there's an unhashed backlog so an operator
// looking at "scan stuck at 44%" can see backfill is running and
// how much remains. Quiet only when there's nothing to do.
if unhashed_total > 0 || backfilled > 0 || errors > 0 {
let remaining = unhashed_total.saturating_sub(backfilled);
info!(
"face_watch: backfilled {}/{} content_hash for library '{}' ({} error(s); {} still need backfill; cap={})",
backfilled, unhashed_total, library.name, errors, remaining, cap
);
}
}
/// Build the face-detection candidate list for a scan tick.
///
/// Returns `(rel_path, content_hash)` for every image file that has a
/// content_hash recorded in image_exif but no row in face_detections
/// yet. Re-querying image_exif here picks up rows the EXIF write loop
/// just inserted alongside any pre-existing rows the watcher walked
/// over — covers both new uploads and the initial backlog scan.
pub fn build_face_candidates(
library: &libraries::Library,
files: &[(PathBuf, String)],
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
) -> Vec<face_watch::FaceCandidate> {
// Restrict to image files; videos aren't face-scanned in v1 (kamadak
// doesn't even register them in image_exif).
let image_paths: Vec<String> = files
.iter()
.filter(|(p, _)| !file_types::is_video_file(p))
.map(|(_, rel)| rel.clone())
.collect();
if image_paths.is_empty() {
return Vec::new();
}
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(Some(library.id), &image_paths)
.unwrap_or_default()
};
// rel_path → content_hash (only rows with a hash; without one we have
// nothing to key face data against).
let mut hash_by_path: HashMap<String, String> = HashMap::with_capacity(exif_records.len());
for record in exif_records {
if let Some(h) = record.content_hash {
hash_by_path.insert(record.file_path, h);
}
}
let mut candidates = Vec::new();
let mut dao = face_dao.lock().expect("face dao");
for rel_path in image_paths {
let Some(hash) = hash_by_path.get(&rel_path) else {
continue;
};
match dao.already_scanned(hash) {
Ok(true) => continue,
Ok(false) => candidates.push(face_watch::FaceCandidate {
rel_path,
content_hash: hash.clone(),
}),
Err(e) => {
warn!("face_watch: already_scanned errored for {}: {:?}", hash, e);
}
}
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::{Arc, Mutex};
use diesel::prelude::*;
use tempfile::TempDir;
use crate::database::models::{InsertImageExif, InsertLibrary};
use crate::database::test::in_memory_db_connection;
use crate::database::{ExifDao, SqliteExifDao, schema};
use crate::faces::{FaceDao, SqliteFaceDao};
use crate::libraries::Library;
/// Everything `setup` hands back to a test: tempdir, library, shared
/// connection, and the two DAOs. Aliased to keep clippy's
/// type-complexity lint satisfied.
type SetupFixture = (
TempDir,
Library,
Arc<Mutex<diesel::SqliteConnection>>,
Arc<Mutex<Box<dyn ExifDao>>>,
Arc<Mutex<Box<dyn FaceDao>>>,
);
/// Build a tempdir-backed library + DAOs sharing a single in-memory
/// SQLite connection (so cross-table joins like
/// `list_unscanned_candidates` see consistent state).
fn setup() -> SetupFixture {
let tmp = TempDir::new().expect("tempdir");
let mut conn = in_memory_db_connection();
// Migration seeds library id=1 with a placeholder root; rewrite it
// to point at the tempdir so `<root>/<rel_path>` resolves to real
// files this test creates.
diesel::update(schema::libraries::table.filter(schema::libraries::id.eq(1)))
.set(schema::libraries::root_path.eq(tmp.path().to_string_lossy().to_string()))
.execute(&mut conn)
.expect("rewrite library 1 root");
// Add a second library so cross-library skip cases have somewhere
// to put their rows.
diesel::insert_into(schema::libraries::table)
.values(InsertLibrary {
name: "other",
root_path: "/tmp/other-test-lib",
created_at: 0,
enabled: true,
excluded_dirs: None,
})
.execute(&mut conn)
.expect("seed second library");
let library = Library {
id: 1,
name: "main".to_string(),
root_path: tmp.path().to_string_lossy().to_string(),
enabled: true,
excluded_dirs: Vec::new(),
};
let shared = Arc::new(Mutex::new(conn));
let exif_dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(
SqliteExifDao::from_shared(Arc::clone(&shared)),
)));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> = Arc::new(Mutex::new(Box::new(
SqliteFaceDao::from_connection(Arc::clone(&shared)),
)));
(tmp, library, shared, exif_dao, face_dao)
}
fn insert_exif(
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
lib_id: i32,
rel: &str,
content_hash: Option<&str>,
) {
let mut dao = exif_dao.lock().unwrap();
dao.store_exif(InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: None,
created_time: 0,
last_modified: 0,
content_hash: content_hash.map(|s| s.to_string()),
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
})
.expect("insert");
}
fn write_image(root: &std::path::Path, rel: &str, bytes: &[u8]) {
let abs = root.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(abs, bytes).expect("write file");
}
#[test]
fn backfill_unhashed_backlog_hashes_missing_rows_in_this_library() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
write_image(tmp.path(), "a.jpg", b"alpha-bytes");
write_image(tmp.path(), "b.jpg", b"bravo-bytes");
insert_exif(&exif_dao, 1, "a.jpg", None);
insert_exif(&exif_dao, 1, "b.jpg", None);
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 2);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(Some(1), &["a.jpg".to_string(), "b.jpg".to_string()])
.unwrap();
assert_eq!(rows.len(), 2);
for r in rows {
assert!(
r.content_hash.is_some(),
"row {} should have a hash",
r.file_path
);
}
}
#[test]
fn backfill_unhashed_backlog_skips_other_libraries_and_missing_files() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
write_image(tmp.path(), "exists.jpg", b"hello");
// Row for this library whose file is missing on disk:
insert_exif(&exif_dao, 1, "ghost.jpg", None);
insert_exif(&exif_dao, 1, "exists.jpg", None);
// Row in the other library — must be skipped (different lib_id).
insert_exif(&exif_dao, 2, "other.jpg", None);
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 1, "only the existing in-library file hashes");
let mut dao = exif_dao.lock().unwrap();
let other = dao
.get_exif_batch(Some(2), &["other.jpg".to_string()])
.unwrap();
assert_eq!(other.len(), 1);
assert!(
other[0].content_hash.is_none(),
"other-library row must remain unhashed"
);
let ghost = dao
.get_exif_batch(Some(1), &["ghost.jpg".to_string()])
.unwrap();
assert_eq!(ghost.len(), 1);
assert!(
ghost[0].content_hash.is_none(),
"missing-on-disk row stays unhashed (reconciliation removes it later)"
);
}
#[test]
fn backfill_unhashed_backlog_respects_per_tick_cap() {
// Env-var-driven cap; the function reads it on every call, so we
// can set it just for this test and unset before returning.
// Serial guard: tests in the same binary may share env, but each
// backfill call re-reads — and we only care that the cap shape
// (success count <= cap, more_remain logged) holds.
unsafe {
std::env::set_var("FACE_HASH_BACKFILL_MAX_PER_TICK", "2");
}
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
for i in 0..5 {
let rel = format!("img_{}.jpg", i);
write_image(tmp.path(), &rel, format!("bytes-{}", i).as_bytes());
insert_exif(&exif_dao, 1, &rel, None);
}
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 2, "cap=2 must bound the per-tick successes");
unsafe {
std::env::remove_var("FACE_HASH_BACKFILL_MAX_PER_TICK");
}
}
#[test]
fn backfill_missing_content_hashes_skips_videos_and_hashed_rows() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
// Two image rows (one already hashed, one not), one video.
write_image(tmp.path(), "fresh.jpg", b"fresh-pixels");
write_image(tmp.path(), "already.jpg", b"already-pixels");
write_image(tmp.path(), "clip.mp4", b"video-bytes");
insert_exif(&exif_dao, 1, "fresh.jpg", None);
insert_exif(&exif_dao, 1, "already.jpg", Some("pre-existing-hash"));
insert_exif(&exif_dao, 1, "clip.mp4", None);
let files: Vec<(PathBuf, String)> = vec![
(tmp.path().join("fresh.jpg"), "fresh.jpg".to_string()),
(tmp.path().join("already.jpg"), "already.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
backfill_missing_content_hashes(&files, &library, &exif_dao);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(
Some(1),
&[
"fresh.jpg".to_string(),
"already.jpg".to_string(),
"clip.mp4".to_string(),
],
)
.unwrap();
let by_path: HashMap<String, Option<String>> = rows
.into_iter()
.map(|r| (r.file_path, r.content_hash))
.collect();
assert!(
by_path["fresh.jpg"].is_some(),
"fresh image must get a hash"
);
assert_eq!(
by_path["already.jpg"].as_deref(),
Some("pre-existing-hash"),
"already-hashed image left untouched"
);
assert!(
by_path["clip.mp4"].is_none(),
"video skipped (not face-scanned, no hash needed via this path)"
);
}
#[test]
fn build_face_candidates_filters_videos_unhashed_and_already_scanned() {
let (tmp, library, _conn, exif_dao, face_dao) = setup();
// Seed image_exif with: hashed unscanned, hashed scanned, unhashed,
// and a video. Files don't need to exist on disk — the function
// doesn't read them, only the DB rows.
insert_exif(&exif_dao, 1, "fresh.jpg", Some("hash-fresh"));
insert_exif(&exif_dao, 1, "scanned.jpg", Some("hash-scanned"));
insert_exif(&exif_dao, 1, "unhashed.jpg", None);
insert_exif(&exif_dao, 1, "clip.mp4", Some("hash-video"));
// Mark `scanned.jpg`'s hash as already detected.
{
let mut dao = face_dao.lock().unwrap();
dao.mark_status(1, "hash-scanned", "scanned.jpg", "no_faces", "test")
.expect("mark scanned");
}
let files: Vec<(PathBuf, String)> = vec![
(tmp.path().join("fresh.jpg"), "fresh.jpg".to_string()),
(tmp.path().join("scanned.jpg"), "scanned.jpg".to_string()),
(tmp.path().join("unhashed.jpg"), "unhashed.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
let candidates = build_face_candidates(&library, &files, &exif_dao, &face_dao);
assert_eq!(
candidates.len(),
1,
"exactly fresh.jpg should be a candidate"
);
assert_eq!(candidates[0].rel_path, "fresh.jpg");
assert_eq!(candidates[0].content_hash, "hash-fresh");
}
// --- True-instant backfill drain (ADR-0001, ticket #118) -------------
use crate::database::SqlitePrecomputedReelDao;
use crate::database::models::{ImageExif, InsertablePrecomputedReel};
use chrono::NaiveDateTime;
const NY: &str = "America/New_York";
fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, s)
.unwrap()
.and_utc()
.timestamp()
}
fn reel_dao_for(
conn: &Arc<Mutex<diesel::SqliteConnection>>,
) -> Arc<Mutex<Box<dyn PrecomputedReelDao>>> {
Arc::new(Mutex::new(Box::new(
SqlitePrecomputedReelDao::from_connection(Arc::clone(conn)),
)))
}
/// Seed a row on the legacy convention: legacy pair set, new-contract
/// triple NULL. Returns the row id.
fn insert_legacy(
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
lib_id: i32,
rel: &str,
date_taken: Option<i64>,
source: Option<&str>,
) -> i32 {
let mut dao = exif_dao.lock().unwrap();
dao.store_exif(InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: source.map(str::to_string),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
})
.expect("insert")
.id
}
fn row(conn: &Arc<Mutex<diesel::SqliteConnection>>, id: i32) -> ImageExif {
let mut c = conn.lock().unwrap();
schema::image_exif::table
.find(id)
.first::<ImageExif>(&mut *c)
.expect("row")
}
fn remaining(exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>, lib_id: i32) -> i64 {
exif_dao
.lock()
.unwrap()
.count_rows_needing_utc_backfill(lib_id)
.unwrap()
}
fn tools_available() -> bool {
let ok = |bin: &str, arg: &str| {
std::process::Command::new(bin)
.arg(arg)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
};
ok("ffmpeg", "-version") && ok("exiftool", "-ver")
}
fn run_ok(cmd: &mut std::process::Command) {
let out = cmd.output().expect("spawn fixture tool");
assert!(
out.status.success(),
"fixture command failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// Tiny H.264 MP4 whose container `creation_time` exiftool reads as
/// QuickTime / Track* groups (→ `utc_direct`).
fn write_container_video(root: &std::path::Path, rel: &str, creation_time: &str) {
run_ok(
std::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=black:s=32x32:d=0.4",
"-r",
"10",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-metadata",
])
.arg(format!("creation_time={creation_time}"))
.arg(root.join(rel)),
);
}
/// Tiny JPEG carrying `DateTimeOriginal` + `OffsetTimeOriginal`.
fn write_exif_jpeg(root: &std::path::Path, rel: &str, dto: &str, offset: &str) {
let abs = root.join(rel);
run_ok(
std::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=gray:s=16x16",
"-frames:v",
"1",
])
.arg(&abs),
);
run_ok(
std::process::Command::new("exiftool")
.arg("-q")
.arg("-overwrite_original")
.arg(format!("-DateTimeOriginal={dto}"))
.arg(format!("-OffsetTimeOriginal={offset}"))
.arg(&abs),
);
}
#[test]
fn utc_backfill_converts_one_row_per_source_and_is_idempotent() {
if !tools_available() {
eprintln!("skipping: ffmpeg/exiftool not on PATH");
return;
}
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
// exif: camera wall 22:30 at +02:00 → 20:30Z. Legacy = wall-as-UTC.
write_exif_jpeg(tmp.path(), "IMG_0001.jpg", "2024:07:04 22:30:00", "+02:00");
let exif_legacy = utc(2024, 7, 4, 22, 30, 0);
let exif_id = insert_legacy(
&exif_dao,
1,
"IMG_0001.jpg",
Some(exif_legacy),
Some("exif"),
);
// exiftool/container: creation_time is UTC. Legacy = the old
// server-local interpretation (EDT, +4h) — the "videos off by 4–5
// hours" row this drain exists to fix.
write_container_video(tmp.path(), "clip.mp4", "2017-06-11T15:58:23Z");
let video_legacy = utc(2017, 6, 11, 19, 58, 23);
let video_id = insert_legacy(
&exif_dao,
1,
"clip.mp4",
Some(video_legacy),
Some("exiftool"),
);
// filename (wall pattern): legacy wall-as-UTC; no evidence → server zone.
let fname = "Screenshot_2014-06-01-20-44-50.png";
write_image(tmp.path(), fname, b"not-a-png");
let fname_wall: NaiveDateTime = NaiveDate::from_ymd_opt(2014, 6, 1)
.unwrap()
.and_hms_opt(20, 44, 50)
.unwrap();
let fname_legacy = fname_wall.and_utc().timestamp();
let fname_id = insert_legacy(&exif_dao, 1, fname, Some(fname_legacy), Some("filename"));
// fs_time: no date anywhere in the file; legacy is already a true instant.
write_image(tmp.path(), "scan.jpg", b"no-exif-here");
let meta = fs::metadata(tmp.path().join("scan.jpg")).unwrap();
let fs_instant: chrono::DateTime<chrono::Utc> =
crate::utils::earliest_fs_time(&meta).unwrap().into();
let fs_instant = fs_instant.timestamp();
let fs_id = insert_legacy(&exif_dao, 1, "scan.jpg", Some(fs_instant), Some("fs_time"));
// manual (pre-#115 override): the operator's wall in the display
// zone — never re-resolved from the file.
write_image(tmp.path(), "edited.jpg", b"no-exif-here-either");
let manual_legacy = utc(2020, 1, 1, 12, 0, 0);
let manual_id = insert_legacy(
&exif_dao,
1,
"edited.jpg",
Some(manual_legacy),
Some("manual"),
);
assert_eq!(remaining(&exif_dao, 1), 5);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 5, "{out:?}");
assert_eq!(out.unresolved, 0, "{out:?}");
let legacy = |r: &ImageExif| (r.date_taken, r.date_taken_source.clone());
let r = row(&conn, exif_id);
assert_eq!(r.date_utc, Some(utc(2024, 7, 4, 20, 30, 0)));
assert_eq!(r.camera_offset_minutes, Some(120));
assert_eq!(r.date_taken_tz_source.as_deref(), Some("offset_tag"));
assert_eq!(legacy(&r), (Some(exif_legacy), Some("exif".into())));
let r = row(&conn, video_id);
assert_eq!(r.date_utc, Some(utc(2017, 6, 11, 15, 58, 23)));
assert_eq!(r.camera_offset_minutes, None);
assert_eq!(r.date_taken_tz_source.as_deref(), Some("utc_direct"));
assert_eq!(legacy(&r), (Some(video_legacy), Some("exiftool".into())));
let r = row(&conn, fname_id);
assert_eq!(r.date_taken_tz_source.as_deref(), Some("assumed_server"));
if let Some(zone) = crate::camera_tz::server_zone() {
let off = crate::camera_tz::offset_minutes_for_zone(zone, fname_wall);
assert_eq!(r.camera_offset_minutes, Some(off));
assert_eq!(
r.date_utc,
Some(crate::camera_tz::wall_to_instant(fname_wall, off))
);
}
assert_eq!(legacy(&r), (Some(fname_legacy), Some("filename".into())));
let r = row(&conn, fs_id);
assert_eq!(r.date_utc, Some(fs_instant));
assert_eq!(r.camera_offset_minutes, None);
assert_eq!(r.date_taken_tz_source.as_deref(), Some("utc_direct"));
assert_eq!(legacy(&r), (Some(fs_instant), Some("fs_time".into())));
let r = row(&conn, manual_id);
// 2020-01-01 12:00 EST (-300) → 17:00Z.
assert_eq!(r.date_utc, Some(utc(2020, 1, 1, 17, 0, 0)));
assert_eq!(r.camera_offset_minutes, Some(-300));
assert_eq!(r.date_taken_tz_source.as_deref(), Some("manual"));
assert_eq!(legacy(&r), (Some(manual_legacy), Some("manual".into())));
// Second pass: nothing eligible, nothing changes, progress is 0.
let ids = [exif_id, video_id, fname_id, fs_id, manual_id];
let snapshot = |conn: &Arc<Mutex<diesel::SqliteConnection>>| {
ids.iter()
.map(|id| {
let r = row(conn, *id);
(
r.date_taken,
r.date_taken_source,
r.date_utc,
r.camera_offset_minutes,
r.date_taken_tz_source,
)
})
.collect::<Vec<_>>()
};
let before = snapshot(&conn);
let again = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(again, UtcBackfillOutcome::default());
assert_eq!(snapshot(&conn), before);
let mut progress = HashMap::new();
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
assert_eq!(progress.get(&1), Some(&0));
}
#[test]
fn utc_backfill_leaves_null_date_rows_and_other_libraries_alone() {
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
write_image(tmp.path(), "1500000000.jpg", b"x");
write_image(tmp.path(), "undated.jpg", b"y");
let ts = 1_500_000_000;
let converted = insert_legacy(&exif_dao, 1, "1500000000.jpg", Some(ts), Some("filename"));
// Both NULL → the legacy drain's job (its writer fills the triple).
let undated = insert_legacy(&exif_dao, 1, "undated.jpg", None, None);
let other = insert_legacy(&exif_dao, 2, "1500000000.jpg", Some(ts), Some("filename"));
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 1);
assert_eq!(row(&conn, converted).date_utc, Some(ts));
assert_eq!(row(&conn, undated).date_utc, None);
assert_eq!(row(&conn, other).date_utc, None);
// Progress counts every row not yet on the new contract.
assert_eq!(remaining(&exif_dao, 1), 1);
}
#[test]
fn utc_backfill_respects_cap_and_missing_files_dont_spend_it() {
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
// Missing files at the lowest ids, ahead of the present ones.
// Timestamp filenames keep the fixture hermetic (never hit exiftool).
let mut missing = Vec::new();
for i in 0..3 {
let ts = 1_400_000_000 + i;
let rel = format!("{ts}.jpg");
missing.push(insert_legacy(
&exif_dao,
1,
&rel,
Some(ts),
Some("filename"),
));
}
for i in 0..5 {
let ts = 1_500_000_000 + i;
let rel = format!("{ts}.jpg");
write_image(tmp.path(), &rel, b"z");
insert_legacy(&exif_dao, 1, &rel, Some(ts), Some("filename"));
}
assert_eq!(remaining(&exif_dao, 1), 8);
let mut progress = HashMap::new();
let mut seen = Vec::new();
// Cursor carried across ticks, as the watcher does.
let mut cursor = 0;
for _ in 0..4 {
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut cursor, 2);
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
seen.push((out.converted, progress[&1]));
}
// Cap holds (2 per tick) and the 3 missing rows never spend it.
assert_eq!(seen, vec![(2, 6), (2, 4), (1, 3), (0, 3)]);
// The missing-file scan retires the rest → progress reaches 0.
{
let mut c = conn.lock().unwrap();
diesel::delete(
schema::image_exif::table.filter(schema::image_exif::id.eq_any(&missing)),
)
.execute(&mut *c)
.unwrap();
}
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
assert_eq!(progress[&1], 0);
}
#[test]
fn utc_backfill_unresolvable_rows_cannot_stall_the_rows_behind_them() {
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
// Present files whose legacy date came from EXIF but that carry no
// date anywhere now: the waterfall would only find fs_time, which
// the drain refuses to write over a tag date. More of them than
// the cap, all at the lowest ids.
for i in 0..4 {
let rel = format!("bad{i}.jpg");
write_image(tmp.path(), &rel, b"no-exif");
insert_legacy(&exif_dao, 1, &rel, Some(1_000_000_000), Some("exif"));
}
let mut good = Vec::new();
for i in 0..2 {
let ts = 1_500_000_000 + i;
let rel = format!("{ts}.jpg");
write_image(tmp.path(), &rel, b"z");
good.push(insert_legacy(
&exif_dao,
1,
&rel,
Some(ts),
Some("filename"),
));
}
let mut cursor = 0;
let mut converted = 0;
for _ in 0..4 {
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut cursor, 2);
converted += out.converted;
}
assert_eq!(converted, 2, "the rows behind the stuck ones still drain");
for id in good {
assert!(row(&conn, id).date_utc.is_some());
}
// The unresolvable rows stay pending (visible in progress), untouched.
assert_eq!(remaining(&exif_dao, 1), 4);
}
#[test]
fn utc_backfill_skips_unavailable_library_then_resumes() {
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
write_image(tmp.path(), "1500000000.jpg", b"z");
let filed = insert_legacy(
&exif_dao,
1,
"1500000000.jpg",
Some(1_500_000_000),
Some("filename"),
);
// A manual row needs no file — it must still wait for the mount.
let manual = insert_legacy(
&exif_dao,
1,
"edited.jpg",
Some(utc(2020, 1, 1, 12, 0, 0)),
Some("manual"),
);
let unmounted = Library {
root_path: tmp.path().join("unmounted").to_string_lossy().to_string(),
..library.clone()
};
// The watcher gate sees it stale…
let health_map = libraries::new_health_map(std::slice::from_ref(&unmounted));
assert!(!libraries::refresh_health(&health_map, &unmounted, true).is_online());
// …and the drain itself converts nothing even if called.
let out = backfill_utc_dates_capped(&unmounted, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out, UtcBackfillOutcome::default());
assert_eq!(row(&conn, filed).date_utc, None);
assert_eq!(row(&conn, manual).date_utc, None);
// Share returns → picked up next tick.
assert!(libraries::refresh_health(&health_map, &library, true).is_online());
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 2);
assert_eq!(remaining(&exif_dao, 1), 0);
}
/// Seed a pre-generated reel covering `month`-`day` (generated 03:00
/// EDT that day, as the nightly pre-gen does). Returns (id, mp4 path).
fn seed_reel(
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
dir: &std::path::Path,
span: &str,
library_key: &str,
month: u32,
day: u32,
) -> (i32, PathBuf) {
let mp4 = dir.join(format!("reel-{span}-{library_key}-{month}-{day}.mp4"));
fs::write(&mp4, b"mp4").unwrap();
let id = reel_dao
.lock()
.unwrap()
.record_reel(&InsertablePrecomputedReel {
span: span.to_string(),
library_key: library_key.to_string(),
cache_key: format!("{span}-{library_key}-{month}-{day}"),
output_path: mp4.to_string_lossy().to_string(),
title: "t".to_string(),
media_count: 1,
render_version: 1,
tz_offset_minutes: -240,
voice: None,
generated_at: utc(2025, month, day, 7, 0, 0),
})
.unwrap();
(id, mp4)
}
fn reel_ids(conn: &Arc<Mutex<diesel::SqliteConnection>>) -> Vec<i32> {
let mut c = conn.lock().unwrap();
schema::precomputed_reels::table
.select(schema::precomputed_reels::id)
.order(schema::precomputed_reels::id.asc())
.load::<i32>(&mut *c)
.unwrap()
}
#[test]
fn utc_backfill_invalidates_exactly_the_moved_days_reels() {
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
let reels_dir = TempDir::new().unwrap();
// Manual wall 2017-06-12 02:00 in NY. The legacy value, read as an
// instant in NY by the old /memories SQL, showed 06-11 22:00; the
// true instant is 06-12 06:00Z = 06-12 02:00 EDT → the day moves
// 06-11 → 06-12.
let moved = insert_legacy(
&exif_dao,
1,
"moved.jpg",
Some(utc(2017, 6, 12, 2, 0, 0)),
Some("manual"),
);
// Unchanged: a timestamp-filename row's legacy value is already the instant.
write_image(tmp.path(), "1400000000.jpg", b"z");
insert_legacy(
&exif_dao,
1,
"1400000000.jpg",
Some(1_400_000_000),
Some("filename"),
);
let (_, mp4_11) = seed_reel(&reel_dao, reels_dir.path(), "day", "1", 6, 11);
let (_, mp4_12) = seed_reel(&reel_dao, reels_dir.path(), "day", "all", 6, 12);
let (other_lib, _) = seed_reel(&reel_dao, reels_dir.path(), "day", "2", 6, 12);
let (other_day, mp4_0301) = seed_reel(&reel_dao, reels_dir.path(), "day", "1", 3, 1);
let (week, _) = seed_reel(&reel_dao, reels_dir.path(), "week", "1", 6, 12);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 2);
assert_eq!(out.day_changed, 1, "{out:?}");
assert_eq!(out.reels_invalidated, 2, "{out:?}");
assert_eq!(row(&conn, moved).date_utc, Some(utc(2017, 6, 12, 6, 0, 0)));
// Exactly the moved days' day reels for this library (+ "all") are gone.
assert_eq!(reel_ids(&conn), vec![other_lib, other_day, week]);
assert!(
!mp4_11.exists() && !mp4_12.exists(),
"stale renders unlinked"
);
assert!(mp4_0301.exists());
// With the ledger row gone the nightly pre-gen's freshness gate
// (`latest_for` must match the new cache key) can't skip → it
// regenerates on its normal schedule; no new scheduler involved.
let latest = reel_dao.lock().unwrap().latest_for("day", "all").unwrap();
assert!(latest.is_none());
}
#[test]
fn utc_backfill_utc_midnight_straddle_marks_nothing() {
let (_tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
let reels_dir = TempDir::new().unwrap();
// Manual wall 2017-06-11 23:30 in NY. Legacy instant 06-11 23:30Z
// (NY 19:30); true instant 06-12 03:30Z (NY 23:30). The UTC date
// flips 06-11 → 06-12, but the display-zone day stays 06-11.
let straddle = insert_legacy(
&exif_dao,
1,
"late.jpg",
Some(utc(2017, 6, 11, 23, 30, 0)),
Some("manual"),
);
seed_reel(&reel_dao, reels_dir.path(), "day", "1", 6, 11);
seed_reel(&reel_dao, reels_dir.path(), "day", "all", 6, 12);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 1);
assert_eq!(out.day_changed, 0);
assert_eq!(out.reels_invalidated, 0);
assert_eq!(
row(&conn, straddle).date_utc,
Some(utc(2017, 6, 12, 3, 30, 0))
);
assert_eq!(reel_ids(&conn).len(), 2);
}
}