fix(cleanup): clear an artist's attachments before the cascade delete (#3066)
`delete_artist_cascade` could abort partway through, and it aborted after the irreversible half. Deleting an artist CASCADEs to Post (post.artist_id is ondelete=CASCADE), which SET NULLs post_attachment.post_id — and `uq_post_attachment_null_post_sha` is a partial UNIQUE on sha256 ALONE WHERE post_id IS NULL. So any two of that artist's attachments sharing a sha collapse onto one another and raise. That shape is ordinary, not corrupt: `_capture_attachment` deliberately writes one row per post over a single sha-addressed blob, so a creator who attaches the same pdf to two posts already has two such rows. A pre-existing filesystem-import row (post_id NULL) with the same sha collides on its own. The images and their on-disk files are deleted and committed in 500-row batches BEFORE the artist row is touched, so the failure landed after them: images gone, artist and posts alive, files unrecoverable. Fix: delete the artist's post_attachment rows explicitly first, matched by artist_id OR by the owning post's artist (artist_id is nullable, so neither arm alone covers every row). `_repoint_post_links` already guards the identical collision class in the reconcile path; this is its artist-cascade counterpart. Migration 0043 reasoned only about upgrade-time safety and never about this later SET NULL. The sha-addressed blobs are deliberately left on disk: one blob backs many rows, so unlinking needs a refcount pass, and this Tier-C op must not delete bytes its own preview never disclosed. Adds `attachments_deleted` to the summary, and two regression tests — the same sha on two posts, and an unrelated NULL-post row that must survive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,88 @@ def test_delete_artist_cascade_idempotent_on_missing(db_sync, tmp_path):
|
||||
assert result["summary"]["images_deleted"] == 0
|
||||
|
||||
|
||||
def test_delete_artist_cascade_survives_same_sha_on_two_posts(db_sync, tmp_path):
|
||||
"""Same file attached to two of the artist's posts must not abort the delete.
|
||||
|
||||
Left to the cascade this raises: artist delete CASCADEs to Post, which SET
|
||||
NULLs post_attachment.post_id, and `uq_post_attachment_null_post_sha`
|
||||
(sha256 alone, WHERE post_id IS NULL) then rejects the second row. That's an
|
||||
ordinary shape — _capture_attachment writes one row per post over one
|
||||
sha-addressed blob by design. Also covers the NULL-artist_id arm of the
|
||||
delete predicate: the second row has no artist_id, only a post that does.
|
||||
"""
|
||||
a = _make_artist(db_sync, slug="casatt")
|
||||
p1 = Post(artist_id=a.id, external_post_id="att-p1")
|
||||
p2 = Post(artist_id=a.id, external_post_id="att-p2")
|
||||
db_sync.add_all([p1, p2])
|
||||
db_sync.flush()
|
||||
|
||||
shared_sha = "ca5a".ljust(64, "0")
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p1.id, artist_id=a.id, sha256=shared_sha,
|
||||
path="/store/ca5a/bundle.zip", original_filename="bundle.zip",
|
||||
ext=".zip", size_bytes=7,
|
||||
))
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p2.id, artist_id=None, sha256=shared_sha,
|
||||
path="/store/ca5a/bundle.zip", original_filename="bundle.zip",
|
||||
ext=".zip", size_bytes=7,
|
||||
))
|
||||
db_sync.commit()
|
||||
artist_id = a.id
|
||||
|
||||
result = cleanup_service.delete_artist_cascade(
|
||||
db_sync, artist_id=artist_id, images_root=tmp_path,
|
||||
)
|
||||
|
||||
assert result["summary"]["attachments_deleted"] == 2
|
||||
assert db_sync.execute(
|
||||
select(func.count(Artist.id)).where(Artist.id == artist_id)
|
||||
).scalar_one() == 0
|
||||
assert db_sync.execute(
|
||||
select(func.count(PostAttachment.id))
|
||||
.where(PostAttachment.sha256 == shared_sha)
|
||||
).scalar_one() == 0
|
||||
|
||||
|
||||
def test_delete_artist_cascade_keeps_unrelated_null_post_attachment(
|
||||
db_sync, tmp_path,
|
||||
):
|
||||
"""A filesystem-import row (post_id NULL) sharing the sha is the other way
|
||||
this collides — and it must SURVIVE: it belongs to no artist, so the
|
||||
cascade has no claim on it."""
|
||||
a = _make_artist(db_sync, slug="casorph")
|
||||
p = Post(artist_id=a.id, external_post_id="orph-p1")
|
||||
db_sync.add(p)
|
||||
db_sync.flush()
|
||||
|
||||
sha = "0rfa".ljust(64, "0")
|
||||
standalone = PostAttachment(
|
||||
post_id=None, artist_id=None, sha256=sha,
|
||||
path="/store/0rfa/manual.pdf", original_filename="manual.pdf",
|
||||
ext=".pdf", size_bytes=3,
|
||||
)
|
||||
db_sync.add(standalone)
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p.id, artist_id=a.id, sha256=sha,
|
||||
path="/store/0rfa/manual.pdf", original_filename="manual.pdf",
|
||||
ext=".pdf", size_bytes=3,
|
||||
))
|
||||
db_sync.commit()
|
||||
artist_id, standalone_id = a.id, standalone.id
|
||||
|
||||
result = cleanup_service.delete_artist_cascade(
|
||||
db_sync, artist_id=artist_id, images_root=tmp_path,
|
||||
)
|
||||
|
||||
assert result["summary"]["attachments_deleted"] == 1
|
||||
surviving = db_sync.execute(
|
||||
select(PostAttachment.id, PostAttachment.post_id)
|
||||
.where(PostAttachment.sha256 == sha)
|
||||
).all()
|
||||
assert surviving == [(standalone_id, None)]
|
||||
|
||||
|
||||
# --- delete_images --------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user