fix(reconcile): preserve from_attachment_id when merging duplicate posts (#73/#87)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m15s

Milestone #73 (reconcile duplicate gallery-dl/native post rows) shipped in
eff6427; closing it out after today's #87 work, which added a seam it didn't
account for. _repoint_post_links drops a loser post's ImageProvenance row on the
(image, post) uniqueness collision — and that row may now carry from_attachment_id
(which archive the file was extracted from). For the exact gallery-dl->native
case this targets, the keeper is the native stub (no archive) and the loser is the
gallery-dl row that extracted the member, so a blind delete silently lost the
containing-archive linkage. Carry from_attachment_id onto the keeper's surviving
row (when NULL) before dropping the collision.

The rarer PostAttachment-collision case (both dup posts captured the same archive
blob) doesn't arise in the targeted scenario — the archive lives only on the
gallery-dl post, so it re-points straight to the keeper and the FK stays valid.

Test: collision merge preserves the loser's from_attachment_id on the keeper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:46:44 -04:00
parent af7f0078bc
commit 77d02f57ae
2 changed files with 56 additions and 0 deletions
+23
View File
@@ -573,6 +573,29 @@ def _repoint_post_links(session: Session, loser_id: int, keeper_id: int) -> None
dup_imgs = select(ImageProvenance.image_record_id).where(
ImageProvenance.post_id == keeper_id
)
# Before dropping the colliding loser rows, carry their from_attachment_id
# (which archive the file came out of, milestone #87) onto the keeper's
# surviving row when the keeper didn't record one. For the gallery-dl→native
# case this very milestone targets, the keeper is the native stub (no
# archive) and the loser is the gallery-dl row that extracted the member, so
# a blind delete would silently lose the containing-archive linkage.
for img_id, att_id in session.execute(
select(ImageProvenance.image_record_id, ImageProvenance.from_attachment_id)
.where(
ImageProvenance.post_id == loser_id,
ImageProvenance.image_record_id.in_(dup_imgs),
ImageProvenance.from_attachment_id.is_not(None),
)
).all():
session.execute(
update(ImageProvenance)
.where(
ImageProvenance.post_id == keeper_id,
ImageProvenance.image_record_id == img_id,
ImageProvenance.from_attachment_id.is_(None),
)
.values(from_attachment_id=att_id)
)
session.execute(
delete(ImageProvenance).where(
ImageProvenance.post_id == loser_id,
+33
View File
@@ -853,3 +853,36 @@ def test_reconcile_dedups_provenance_collision(db_sync, tmp_path):
select(ImageProvenance.post_id).where(ImageProvenance.image_record_id == img_id)
).scalars().all()
assert prov == [native_id]
def test_reconcile_preserves_from_attachment_on_provenance_collision(db_sync, tmp_path):
# The gallery-dl loser row recorded which archive the image came out of
# (#87); the native keeper row didn't. Merging drops the loser provenance row
# on the (image, post) collision — but must first carry its from_attachment_id
# onto the keeper so the containing-archive linkage survives.
a = _make_artist(db_sync, slug="rc4")
img = _make_image(db_sync, artist=a, path=str(tmp_path / "z.jpg"), sha256="e" * 64)
legacy = Post(artist_id=a.id, external_post_id="9", raw_metadata={"post_id": 77})
native = Post(artist_id=a.id, external_post_id="77", raw_metadata={"post_id": 77})
db_sync.add_all([legacy, native])
db_sync.flush()
att = PostAttachment(
post_id=legacy.id, artist_id=a.id, sha256="f" * 64,
path=str(tmp_path / "bundle.cbz"), original_filename="bundle.cbz",
ext=".cbz", size_bytes=9,
)
db_sync.add(att)
db_sync.flush()
db_sync.add(ImageProvenance(
image_record_id=img.id, post_id=legacy.id, from_attachment_id=att.id,
))
db_sync.add(ImageProvenance(image_record_id=img.id, post_id=native.id))
img_id, native_id, att_id = img.id, native.id, att.id
db_sync.commit()
cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=False)
rows = db_sync.execute(
select(ImageProvenance.post_id, ImageProvenance.from_attachment_id)
.where(ImageProvenance.image_record_id == img_id)
).all()
assert rows == [(native_id, att_id)]