fix(versions): review findings on PR #193

- A version no longer hides behind an original that duplicates resolve
  demoted (visible_version_clause, hidden_version_rows).
- Recording a link drops the version's faces, CLIP data and clip failures
  (covers link-at-ingest and link_versions --apply).
- The display file must live in the original's library; otherwise the
  original shows, and PUT /image/versions/display answers 400.
- link_new_files looks up hashes in chunks of 500 and logs errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 14:27:54 -04:00
1 parent 6135034d09
commit 9f50843479
6 files changed
+402 -47

No files matched your search

+48
View File
@@ -928,4 +928,52 @@ mod tests {
let report = apply(&mut dao, &[], &reviewed, &stale_rows);
assert_eq!(report.stale, 1);
}
#[test]
fn apply_drops_derived_data_of_an_already_ingested_version() {
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
let exec = |sql: &str| {
diesel::RunQueryDsl::execute(diesel::sql_query(sql), &mut *conn.lock().unwrap())
.unwrap_or_else(|e| panic!("{sql}: {e}"));
};
exec(
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash, clip_embedding, clip_model_version) VALUES (1, 'v.png', 0, 0, 'v', x'00', 'm')",
);
exec(
"INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, source, status, model_version, created_at) VALUES (1, 'v', 'v.png', 0.1, x'00', 'auto', 'detected', 'm', 0)",
);
exec(
"INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) VALUES (1, 'v.png', 'v', 'boom', 0)",
);
let mut dao = SqliteVersionDao::from_shared(conn.clone());
let rows = vec![row("v.png", "v", None)];
let reviewed = vec![ReviewedLink {
method: "phash".into(),
library_id: 1,
version_path: "v.png".into(),
kind: "edit".into(),
version_hash: "v".into(),
original_hash: "o".into(),
references: vec![],
}];
assert_eq!(apply(&mut dao, &[], &reviewed, &rows).linked, 1);
#[derive(diesel::QueryableByName)]
struct N {
#[diesel(sql_type = diesel::sql_types::BigInt)]
n: i64,
}
for sql in [
"SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = 'v'",
"SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = 'v'",
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = 'v' AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)",
] {
let n = diesel::RunQueryDsl::get_result::<N>(
diesel::sql_query(sql),
&mut *conn.lock().unwrap(),
)
.unwrap()
.n;
assert_eq!(n, 0, "{sql}");
}
}
}
+20 -3
View File
@@ -159,9 +159,16 @@ pub fn build_response(
});
}
let display_hash = dao
.get_display(original_hash)?
.filter(|d| versions.iter().any(|v| &v.hash == d));
// Clients fetch the display file through the original's library, so a
// choice with no row there reads as the original (as `/photos` does).
let display_hash = dao.get_display(original_hash)?.filter(|d| {
versions.iter().any(|v| {
&v.hash == d
&& original
.as_ref()
.is_none_or(|o| o.library_id == v.library_id)
})
});
Ok(VersionsResponse {
original,
versions,
@@ -232,6 +239,16 @@ pub async fn put_display(
if !current.versions.iter().any(|v| v.hash == hash) {
return HttpResponse::BadRequest().body("display_hash is not a version of this photo");
}
// The display file is fetched through the original's library.
let original_lib = current.original.as_ref().map(|o| o.library_id);
if !current
.versions
.iter()
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
{
return HttpResponse::BadRequest()
.body("display_hash has no file in the original's library");
}
}
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
warn!("put_display failed: {:?}", e);
+101
View File
@@ -837,3 +837,104 @@ async fn face_stats_denominator_leaves_versions_out() {
// denominator because they are never scanned.
assert_eq!(stats.total_photos, 2);
}
#[actix_rt::test]
async fn linking_at_ingest_drops_derived_data_of_an_already_ingested_version() {
let fx = fx();
let original = fx.put("scan.jpg", b"original bytes");
let xmp = producer_xmp(&original, "restored", &[]);
let vh = fx.put_png("renamed.png", Some(&xmp), 1);
fx.exec(&format!(
"UPDATE image_exif SET clip_embedding = x'00', clip_model_version = 'm' \
WHERE content_hash = '{vh}'"
));
fx.exec(&format!(
"INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, \
source, status, model_version, created_at) \
VALUES (1, '{vh}', 'renamed.png', 0.1, x'00', 'auto', 'detected', 'm', 0)"
));
fx.exec(&format!(
"INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) \
VALUES (1, 'renamed.png', '{vh}', 'boom', 0)"
));
let files = vec![(fx.root.join("renamed.png"), "renamed.png".to_string())];
assert_eq!(
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
1
);
assert_eq!(
fx.count(&format!(
"SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = '{vh}'"
)),
0
);
assert_eq!(
fx.count(&format!(
"SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = '{vh}'"
)),
0
);
assert_eq!(
fx.count(&format!(
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{vh}' \
AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)"
)),
0
);
}
#[actix_rt::test]
async fn link_new_files_links_across_more_than_one_lookup_chunk() {
let fx = fx();
let original = fx.put("scan.jpg", b"original bytes");
let xmp = producer_xmp(&original, "restored", &[]);
let vh = fx.put_png("zz/renamed.png", Some(&xmp), 1);
// Enough unrelated PNG paths to force several lookup chunks; the real
// one sits in the last.
let mut files: Vec<(PathBuf, String)> = (0..1200)
.map(|i| (fx.root.join(format!("n/{i}.png")), format!("n/{i}.png")))
.collect();
files.push((fx.root.join("zz/renamed.png"), "zz/renamed.png".to_string()));
assert_eq!(
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
1
);
assert!(
fx.state
.version_dao
.lock()
.unwrap()
.get_link(&vh)
.unwrap()
.is_some()
);
}
#[actix_rt::test]
async fn display_choice_needs_a_file_in_the_originals_library() {
let fx = fx();
let original = fx.put("scan.jpg", b"original bytes");
let xmp = producer_xmp(&original, "restored", &[]);
let vh = fx.put_png("aiedits/scan.jpg.restored.png", Some(&xmp), 1);
fx.link(&vh, &original, "aiedits/scan.jpg.restored.png");
// The version's bytes live only in a second library.
fx.exec("INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two-nope', 0)");
fx.exec(
"UPDATE image_exif SET library_id = 2 WHERE rel_path = 'aiedits/scan.jpg.restored.png'",
);
let app = photos_app!(fx);
let req = awtest::TestRequest::put()
.uri("/image/versions/display")
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
.set_json(serde_json::json!({"path": "scan.jpg", "display_hash": vh}))
.to_request();
assert_eq!(awtest::call_service(&app, req).await.status(), 400);
// Clearing is still fine.
let req = awtest::TestRequest::put()
.uri("/image/versions/display")
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
.set_json(serde_json::json!({"path": "scan.jpg", "display_hash": null}))
.to_request();
assert_eq!(awtest::call_service(&app, req).await.status(), 200);
}
+24 -11
View File
@@ -221,22 +221,35 @@ pub fn link_new_files(
return 0;
}
let rels: Vec<String> = pngs.iter().map(|(_, r)| r.clone()).collect();
let rows = exif_dao
.lock()
.expect("Unable to lock ExifDao")
.get_exif_batch(Some(library.id), &rels)
.unwrap_or_default();
// Chunked: a first scan can register far more paths than SQLite's
// bound-variable limit allows in one `IN (...)`.
let mut rows = Vec::with_capacity(rels.len());
for chunk in rels.chunks(500) {
match exif_dao
.lock()
.expect("Unable to lock ExifDao")
.get_exif_batch(Some(library.id), chunk)
{
Ok(r) => rows.extend(r),
Err(e) => warn!(
"versions: XMP link lookup failed for {} paths in '{}': {:?}",
chunk.len(),
library.name,
e
),
}
}
let hashes: std::collections::HashMap<&str, &str> = rows
.iter()
.filter_map(|r| Some((r.file_path.as_str(), r.content_hash.as_deref()?)))
.collect();
let mut linked = 0;
for (abs, rel) in pngs {
let Some(hash) = rows
.iter()
.find(|r| &r.file_path == rel)
.and_then(|r| r.content_hash.clone())
else {
let Some(hash) = hashes.get(rel.as_str()) else {
continue;
};
if let Some(link) = xmp_link(library, rel, abs, &hash)
if let Some(link) = xmp_link(library, rel, abs, hash)
&& record_link(version_dao, &link)
{
linked += 1;