fix(review): harden write-back, confirmation gate, and edge cases

- write-back: call upsert_tag inside _apply_tag's try so a tag-create failure
  is caught per-asset and apply_all no longer aborts mid-batch (was P1)
- write-back: surface _pipeline/processed write failures in the result instead
  of discarding them (was reported as success)
- ui: add title to the high confidence badge so approve-high-confidence's
  pre-action count is non-zero; confirm() before single-cluster apply
- core: Store context manager; close DB connection even if a route raises;
  guard split_cluster against a first-member boundary (empty cluster); add
  writeback_log lookup index
- ingest: split thumbnail download/write error handling and clean up the
  .tmp file on a write failure
- tests: upsert/processed write-failure regression tests + split-guard test
This commit is contained in:
2026-06-27 17:54:38 +02:00
parent d67b677b6a
commit ae093881fa
8 changed files with 89 additions and 17 deletions
+13 -4
View File
@@ -45,14 +45,23 @@ def run_ingest(client, store, thumbs_dir, *, date_from=None, date_to=None,
if need_thumb:
try:
data = client.download_thumbnail(a["id"])
# Write to a temp path and os.replace into place so a failure
# never leaves a 0-byte <id>.jpg behind.
tmp_path = f"{thumb_path}.tmp"
except Exception:
log.exception("Thumbnail download failed for asset %s", a["id"])
continue
# Write to a temp path and os.replace into place so a failure
# never leaves a 0-byte <id>.jpg behind; clean up the temp file
# if the write itself fails (e.g. disk full).
tmp_path = f"{thumb_path}.tmp"
try:
with open(tmp_path, "wb") as f:
f.write(data)
os.replace(tmp_path, thumb_path)
except Exception:
log.exception("Thumbnail download failed for asset %s", a["id"])
log.exception("Thumbnail write failed for asset %s", a["id"])
try:
os.unlink(tmp_path)
except OSError:
pass
continue
store.upsert_asset(Asset(
+4 -6
View File
@@ -113,9 +113,8 @@ def apply_one(cid):
# Lazy import: app.writeback is owned by a later task and may be absent
# at app-startup; importing here keeps the blueprint importable regardless.
from app.writeback import apply_cluster
s = _store()
res = apply_cluster(_client(), s, cid)
s.close()
with _store() as s: # close even if the Immich write-back raises
res = apply_cluster(_client(), s, cid)
return jsonify(res)
@@ -123,7 +122,6 @@ def apply_one(cid):
def apply_everything():
# Lazy import: see apply_one above.
from app.writeback import apply_all
s = _store()
results = apply_all(_client(), s)
s.close()
with _store() as s: # close even if the Immich write-back raises
results = apply_all(_client(), s)
return jsonify({"results": results})
+2
View File
@@ -136,6 +136,8 @@ function clusterReview() {
location.reload();
},
async apply() {
// Write-back needs explicit confirmation (mirrors applyAll/merge).
if (!confirm('Apply this clusters decision to Immich?')) return;
const r = await this.post(`/cluster/${this.selected}/apply`, {});
alert(`Applied ${r.succeeded.length}, failed ${r.failed.length}.`);
},
+11 -6
View File
@@ -3,11 +3,15 @@ from photoflow.immich import pipeline
APPLYABLE = ("approved", "non_trip", "skipped")
def _apply_tag(client, store, asset_ids, action, tag, tag_id):
def _apply_tag(client, store, asset_ids, action, tag):
todo = [a for a in asset_ids if not store.already_applied(a, action, tag)]
if not todo:
return [], []
try:
# upsert_tag is part of the write: a failure here must be caught and
# recorded like a tag_assets failure, not propagate out of apply_cluster
# (which would abort the whole apply_all batch and skip later clusters).
tag_id = client.upsert_tag(tag)
client.tag_assets(tag_id, todo)
except Exception as e: # noqa: BLE001 — recorded, surfaced
for a in todo:
@@ -29,12 +33,11 @@ def apply_cluster(client, store, cluster_id) -> dict:
if c.status == "approved":
tag = c.decided_name or c.suggested_name
ok, fail = _apply_tag(client, store, included, "trip", tag, client.upsert_tag(tag))
ok, fail = _apply_tag(client, store, included, "trip", tag)
succeeded += ok
failed += fail
elif c.status == "non_trip":
ok, fail = _apply_tag(client, store, included, "non-trip", pipeline.NON_TRIP,
client.upsert_tag(pipeline.NON_TRIP))
ok, fail = _apply_tag(client, store, included, "non-trip", pipeline.NON_TRIP)
succeeded += ok
failed += fail
# 'skipped': no content/non-trip tag, only processed below.
@@ -42,8 +45,10 @@ def apply_cluster(client, store, cluster_id) -> dict:
failed_ids = {i for i, _ in failed}
proc_targets = [a for a in included if a not in failed_ids]
if proc_targets:
_apply_tag(client, store, proc_targets, "processed", pipeline.PROCESSED,
client.upsert_tag(pipeline.PROCESSED))
# Surface processed-marker failures too — a discarded return here makes a
# failed _pipeline/processed write look like success in the CLI/UI summary.
_, proc_fail = _apply_tag(client, store, proc_targets, "processed", pipeline.PROCESSED)
failed += proc_fail
for a in proc_targets:
if store.already_applied(a, "processed", pipeline.PROCESSED):
store.mark_processed(a)