Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
This commit was merged in pull request #87.
This commit is contained in:
@@ -139,24 +139,25 @@ def count_tag_associations(session: Session, *, tag_id: int) -> int:
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def find_unused_tags(
|
||||
session: Session, *, limit: int | None = None,
|
||||
) -> list[Tag]:
|
||||
"""Tags genuinely referenced by nothing — safe to sweep.
|
||||
def _unused_tag_conditions() -> list:
|
||||
"""The WHERE conditions that define an 'unused' tag — the SINGLE source of
|
||||
truth shared by find_unused_tags (preview sample), the dry-run count, AND the
|
||||
live delete, so the preview can NEVER diverge from what the delete removes.
|
||||
|
||||
Sorted by name. Used by both dry-run preview and the live prune. A tag is
|
||||
"unused" iff it has zero references across ALL the ways a tag can be in use:
|
||||
- image_tag (applied to an image)
|
||||
- series_page (a series tag with ordered pages)
|
||||
- series_chapter (a series tag with chapters but no pages yet, e.g.
|
||||
all-placeholder)
|
||||
- tag.fandom_id (a fandom referenced by a character)
|
||||
A tag is "unused" iff it has zero references across ALL the ways a tag can be
|
||||
in use:
|
||||
- image_tag (applied to an image)
|
||||
- series_page (a series tag with ordered pages)
|
||||
- series_chapter (a series tag with chapters but no pages yet)
|
||||
- tag.fandom_id (a fandom referenced by a character)
|
||||
|
||||
The fandom check is essential: fandom tags are NEVER applied to images — a
|
||||
character carries its fandom via fandom_id — so without it every assigned
|
||||
fandom looked "unused", and the FK is ondelete=SET NULL, so deleting one
|
||||
would silently strip the fandom off all its characters (operator-flagged
|
||||
2026-06-08).
|
||||
fandom looks "unused", and the FK is ondelete=SET NULL, so deleting one
|
||||
silently strips the fandom off all its characters. The delete had only the
|
||||
first two checks while the preview sample had all four, so the preview showed
|
||||
a safe list but the delete removed every fandom anyway (operator-flagged
|
||||
2026-06-08). Defining the predicate once makes that impossible.
|
||||
"""
|
||||
used_via_image_tag = select(image_tag.c.tag_id).distinct()
|
||||
used_via_series = select(SeriesPage.series_tag_id).where(
|
||||
@@ -166,14 +167,20 @@ def find_unused_tags(
|
||||
used_via_fandom = select(Tag.fandom_id).where(
|
||||
Tag.fandom_id.is_not(None)
|
||||
).distinct()
|
||||
stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.id.not_in(used_via_image_tag))
|
||||
.where(Tag.id.not_in(used_via_series))
|
||||
.where(Tag.id.not_in(used_via_chapter))
|
||||
.where(Tag.id.not_in(used_via_fandom))
|
||||
.order_by(Tag.name)
|
||||
)
|
||||
return [
|
||||
Tag.id.not_in(used_via_image_tag),
|
||||
Tag.id.not_in(used_via_series),
|
||||
Tag.id.not_in(used_via_chapter),
|
||||
Tag.id.not_in(used_via_fandom),
|
||||
]
|
||||
|
||||
|
||||
def find_unused_tags(
|
||||
session: Session, *, limit: int | None = None,
|
||||
) -> list[Tag]:
|
||||
"""Tags genuinely referenced by nothing — safe to sweep. Sorted by name.
|
||||
Shares its predicate with the live prune via _unused_tag_conditions()."""
|
||||
stmt = select(Tag).where(*_unused_tag_conditions()).order_by(Tag.name)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return list(session.execute(stmt).scalars().all())
|
||||
@@ -381,30 +388,21 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
|
||||
|
||||
Implementation note: the previous SELECT-ids → DELETE-WHERE-IN
|
||||
pattern was vulnerable to the psycopg 65535-parameter ceiling on
|
||||
libraries with tag explosions. The live delete now runs a single
|
||||
DELETE with the same NOT-IN predicate find_unused_tags uses, so
|
||||
the row count scales without binding every id as a parameter.
|
||||
Audit 2026-06-02.
|
||||
libraries with tag explosions. The live delete runs a single DELETE
|
||||
with the SAME predicate (_unused_tag_conditions) the preview uses, so
|
||||
the row count scales without binding every id as a parameter AND the
|
||||
delete can never remove a tag the preview deemed safe. Audit 2026-06-02;
|
||||
predicate unified 2026-06-08.
|
||||
"""
|
||||
conditions = _unused_tag_conditions()
|
||||
sample_rows = find_unused_tags(session, limit=50)
|
||||
sample = [t.name for t in sample_rows]
|
||||
used_via_image_tag = select(image_tag.c.tag_id).distinct()
|
||||
used_via_series = select(SeriesPage.series_tag_id).where(
|
||||
SeriesPage.series_tag_id.is_not(None)
|
||||
).distinct()
|
||||
if dry_run:
|
||||
count = session.execute(
|
||||
select(func.count())
|
||||
.select_from(Tag)
|
||||
.where(Tag.id.not_in(used_via_image_tag))
|
||||
.where(Tag.id.not_in(used_via_series))
|
||||
select(func.count()).select_from(Tag).where(*conditions)
|
||||
).scalar_one()
|
||||
return {"count": count, "sample_names": sample}
|
||||
result = session.execute(
|
||||
Tag.__table__.delete()
|
||||
.where(Tag.id.not_in(used_via_image_tag))
|
||||
.where(Tag.id.not_in(used_via_series))
|
||||
)
|
||||
result = session.execute(Tag.__table__.delete().where(*conditions))
|
||||
session.commit()
|
||||
return {"deleted": result.rowcount or 0, "sample_names": sample}
|
||||
|
||||
|
||||
@@ -362,6 +362,43 @@ def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
|
||||
assert "bye" not in surviving_names
|
||||
|
||||
|
||||
def test_prune_unused_tags_commit_spares_fandom_and_chaptered_series(
|
||||
db_sync, tmp_path,
|
||||
):
|
||||
# The LIVE delete (not just the preview) must use the same predicate — it
|
||||
# previously had only the image_tag/series_page checks and deleted every
|
||||
# fandom the preview correctly excluded (operator-flagged 2026-06-08, real
|
||||
# data loss). Both dry-run count and the commit must spare referenced tags.
|
||||
fandom = _make_tag(db_sync, name="Creux", kind=TagKind.fandom)
|
||||
char = _make_tag(db_sync, name="OcChar", kind=TagKind.character)
|
||||
char.fandom_id = fandom.id
|
||||
# The character itself is used (tagged on an image) — that is the real-world
|
||||
# shape: characters survive on their image_tag rows, and the fandom they
|
||||
# point at must survive with them.
|
||||
a = _make_artist(db_sync, slug="psf")
|
||||
img = _make_image(
|
||||
db_sync, artist=a, path=str(tmp_path / "c.jpg"), sha256="d" * 64,
|
||||
)
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=char.id,
|
||||
))
|
||||
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series)
|
||||
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1))
|
||||
_make_tag(db_sync, name="GenuinelyUnused")
|
||||
db_sync.commit()
|
||||
|
||||
# dry-run count agrees with the delete: only the 1 truly-unused tag.
|
||||
assert cleanup_service.prune_unused_tags(db_sync, dry_run=True)["count"] == 1
|
||||
result = cleanup_service.prune_unused_tags(db_sync, dry_run=False)
|
||||
assert result["deleted"] == 1
|
||||
|
||||
surviving = db_sync.execute(select(Tag.name)).scalars().all()
|
||||
assert "OcChar" in surviving # used character
|
||||
assert "Creux" in surviving # fandom referenced by a character
|
||||
assert "EmptySeries" in surviving # series referenced by a chapter
|
||||
assert "GenuinelyUnused" not in surviving
|
||||
|
||||
|
||||
# --- reset_content_tagging ------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user