Files
FabledCurator/backend/app/tasks/admin.py
T
bvandeusen 41652db20f
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m14s
feat(maintenance): retroactive video-dedup action — preview + apply (#871)
Phase 2 of #871: clean up the duplicate videos already in the library (the #859
"same video from multiple sources" clutter). Import-time dedup (Phase 1) only
prevents NEW dups; this is the operator-triggered cleanup of existing ones.

cleanup_service.dedup_videos(dry_run):
- backfill_video_durations: re-probe NULL-duration videos (pre-#871 rows) so the
  existing library participates; idempotent (only NULL rows), writes a negative
  sentinel for un-probeable files so they're neither re-probed forever nor matched.
- find_video_dup_groups: cluster same-artist videos by duration (±tol) + aspect,
  anchored per cluster to bound the span (no chain drift); keeper = highest pixel
  area then bytes. Reuses the importer's _VIDEO_DUP_* tolerances.
- apply: re-point each loser's post links to the keeper (so no post loses the
  video) THEN delete the redundant records + files via delete_images (cascade).
  dry_run shares the same discovery predicate and returns the projection only
  (rule 93). Tags on a loser are NOT merged (noted; videos rarely hand-curated).

- dedup_videos_task (maintenance queue; summary → task_run.metadata).
- POST /maintenance/dedup-videos {dry_run} + GET /maintenance/task-result/<id> so
  the card shows the dry-run projection before the destructive apply.
- VideoDedupCard: Preview → shows groups/redundant/reclaimable, then Apply behind
  a confirm dialog. Mounted in the Maintenance panel.

Tests: dedup collapses + re-links the loser's post to the keeper + removes the
file; dry-run deletes nothing; distinct durations aren't grouped; task registered.
(Migration 0052 for duration_seconds already shipped with Phase 1.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:31:50 -04:00

312 lines
13 KiB
Python

"""FC-3k: admin destructive Celery tasks.
Two long-running ops on the maintenance queue. task_run lifecycle is
captured automatically by FC-3i signals — these tasks just return
their summary dict so it lands in task_run.metadata (via Celery's
result backend) for the dashboard to surface.
Soft/hard time limits inherit the FC-3i recovery sweep: a runaway
task gets killed and flipped to status='timeout' by
recover_stalled_task_runs.
"""
from __future__ import annotations
import logging
from pathlib import Path
from sqlalchemy import delete, select
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..models import ImageRecord
from ..services import cleanup_service
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
@celery.task(
name="backend.app.tasks.admin.delete_artist_cascade_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def delete_artist_cascade_task(self, *, artist_id: int) -> dict:
"""Wraps cleanup_service.delete_artist_cascade. Returns the
service's summary dict for FC-3i task_run.metadata capture."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.delete_artist_cascade(
session, artist_id=artist_id, images_root=IMAGES_ROOT,
)
# Orphan repair (#859). Safety guard: an NFS/filesystem stall makes EVERY file
# look missing — never delete records en masse on that basis. If a non-trivial
# sample comes back mostly missing, ABORT without deleting (assume the FS is
# unhealthy, not that the library evaporated). Operator-triggered ONLY — NOT a
# periodic sweep, precisely to avoid an unattended run firing during an NFS blip.
_ORPHAN_MIN_SAMPLE = 50
_ORPHAN_MAX_MISSING_FRAC = 0.10
@celery.task(
name="backend.app.tasks.admin.prune_missing_file_records_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def prune_missing_file_records_task(self) -> dict:
"""Delete ImageRecords whose backing file is gone from disk (orphans — e.g.
the external-attach unlink bug #859). Every FK to image_record is CASCADE /
SET NULL, so a Core DELETE cleans provenance, series pages, predictions and
tag links; leftover thumbnails are unlinked best-effort. Returns a summary.
Aborts WITHOUT deleting if a non-trivial sample is mostly missing (a
filesystem/NFS stall, not real orphans) — see the guard constants above.
"""
SessionLocal = _sync_session_factory()
checked = 0
missing_ids: list[int] = []
thumbs: list[str] = []
last_id = 0
with SessionLocal() as session:
while True:
rows = session.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.thumbnail_path)
.where(ImageRecord.id > last_id)
.order_by(ImageRecord.id)
.limit(1000)
).all()
if not rows:
break
for rid, path, thumb in rows:
last_id = rid
checked += 1
if not (IMAGES_ROOT / path).exists():
missing_ids.append(rid)
if thumb:
thumbs.append(thumb)
if not missing_ids:
return {"checked": checked, "missing": 0, "deleted": 0}
frac = len(missing_ids) / checked if checked else 0.0
if checked >= _ORPHAN_MIN_SAMPLE and frac > _ORPHAN_MAX_MISSING_FRAC:
log.warning(
"orphan-repair ABORTED: %d/%d (%.0f%%) records missing on disk — "
"likely a filesystem/NFS problem, not real orphans. No deletions.",
len(missing_ids), checked, frac * 100,
)
return {
"checked": checked, "missing": len(missing_ids), "deleted": 0,
"aborted": "too many missing — filesystem problem suspected",
}
deleted = 0
for i in range(0, len(missing_ids), 500): # keep well under psycopg's param ceiling
chunk = missing_ids[i:i + 500]
session.execute(delete(ImageRecord).where(ImageRecord.id.in_(chunk)))
deleted += len(chunk)
session.commit()
for t in thumbs: # cosmetic — outside the txn, never fail the repair on these
try:
(IMAGES_ROOT / t).unlink(missing_ok=True)
except OSError:
pass
log.info("orphan-repair: checked=%d missing=%d deleted=%d", checked, len(missing_ids), deleted)
return {"checked": checked, "missing": len(missing_ids), "deleted": deleted}
@celery.task(
name="backend.app.tasks.admin.dedup_videos_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def dedup_videos_task(self, dry_run: bool = False) -> dict:
"""Tier-1 video dedup (#871): re-probe NULL-duration videos, cluster by
artist + duration + aspect, keep the highest-res copy per cluster. dry_run
returns the projection (groups/redundant/reclaimable bytes) WITHOUT deleting;
apply re-points each loser's post links to the keeper then deletes the
redundant records + files. Operator-triggered; the summary lands in
task_run.metadata (FC-3i) for the Maintenance card to surface."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.dedup_videos(
session, images_root=IMAGES_ROOT, dry_run=dry_run,
)
@celery.task(
name="backend.app.tasks.admin.bulk_delete_images_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=900, time_limit=1200, # 15 min / 20 min
)
def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
"""Wraps cleanup_service.delete_images."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.delete_images(
session, image_ids=image_ids, images_root=IMAGES_ROOT,
)
# Time-box one chunk well under the soft limit so a large archive back-catalog
# can't run the task into the Celery time limit (or hog the maintenance_long
# lane). The task re-enqueues itself with the resume cursor until the scan is
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
# timeout, then re-queue so other work keeps flowing).
_REEXTRACT_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.reextract_archive_attachments_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
re-extract PostAttachments that are actually archives but were filed
opaquely before the magic-byte gate, and link their members to the post.
Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
chunk cut, re-enqueues from where it stopped so a big backlog finishes across
chunks instead of dying at the soft limit."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
summary = cleanup_service.reextract_archive_attachments(
session, images_root=IMAGES_ROOT,
time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
)
# More attachments past this chunk's cursor — continue in the next.
if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
log.info(
"reextract chunk done (%d scanned, %d archives, resume after id %s) "
"— re-enqueuing to continue",
summary.get("scanned", 0), summary.get("archives", 0),
summary["resume_after_id"],
)
reextract_archive_attachments_task.delay(summary["resume_after_id"])
return summary
# Time-box one chunk well under the soft limit so a large back-catalog (the
# first run recases the whole booru vocabulary) can't run the task into the
# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The
# task re-enqueues itself until nothing remains (idempotent — already-canonical
# groups are skipped). 600s keeps each chunk short enough that the recovery
# sweep and other maintenance tasks interleave on the concurrency-1 queue.
_NORMALIZE_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.normalize_tags_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def normalize_tags_task(self) -> dict:
"""Wraps tag_service.normalize_existing_tags (#714): Title-Case the
back-catalog and merge case/whitespace-variant duplicate tags via the
tested async merge path. Time-boxed + self-resuming so a huge first run
finishes across chunks instead of timing out. Runs under its own asyncio
loop + per-task async engine (NullPool), mirroring download_source."""
import asyncio
from ..services.tag_service import normalize_existing_tags
from ._async_session import async_session_factory
async def _run() -> dict:
# lock_timeout=30s: a per-group merge repoints FKs across image_tag and
# series_page; if a statement blocks on a lock (e.g. behind a schema
# migration holding ACCESS EXCLUSIVE on series_page — the exact wedge that
# made this task run to the 40-min hard limit with no progress,
# operator-flagged 2026-06-07), it now fails fast. The per-group handler
# catches it (rollback + error++) and the loop continues, so one blocked
# group can't strand the whole chunk.
async_factory, async_engine = async_session_factory(
server_settings={"lock_timeout": "30s"}
)
try:
async with async_factory() as session:
# normalize_existing_tags commits per group internally.
return await normalize_existing_tags(
session, dry_run=False,
time_budget_seconds=_NORMALIZE_CHUNK_SECONDS,
)
finally:
await async_engine.dispose()
summary = asyncio.run(_run())
# More groups to canonicalize than fit this chunk — continue in the next.
if summary.get("partial") and summary.get("remaining", 0) > 0:
log.info(
"normalize_tags_task chunk done (%d processed, %d remaining) — "
"re-enqueuing to continue",
summary.get("groups_processed", 0), summary["remaining"],
)
normalize_tags_task.delay()
return summary
# Time-box one rescan chunk well under the soft limit and re-enqueue from the
# cursor — scoring every post against its artist's series is O(posts) and grows
# with the library (FC-6.3). Mirrors normalize_tags_task.
_SERIES_RESCAN_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.rescan_series_suggestions_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
"""Score posts against their artist's series and write pending suggestions
(FC-6.3). Settings-gated; time-boxed + self-resuming from a post-id cursor.
Per-task async engine (NullPool) under its own asyncio loop, like normalize."""
import asyncio
from ..models import ImportSettings
from ..services.series_match_service import SeriesMatchService
from ._async_session import async_session_factory
async def _run() -> dict:
async_factory, async_engine = async_session_factory()
try:
async with async_factory() as session:
settings = await ImportSettings.load(session)
if not settings.series_suggest_enabled:
return {"skipped": "series suggestions disabled"}
threshold = settings.series_suggest_threshold
return await SeriesMatchService(session).rescan(
threshold=threshold,
time_budget_seconds=_SERIES_RESCAN_CHUNK_SECONDS,
after_post_id=after_post_id,
)
finally:
await async_engine.dispose()
summary = asyncio.run(_run())
if summary.get("partial") and summary.get("resume_after_id", 0) > after_post_id:
log.info(
"rescan_series_suggestions chunk done (%d scanned, %d suggested, "
"resume after %s) — re-enqueuing",
summary.get("scanned", 0), summary.get("suggested", 0),
summary["resume_after_id"],
)
rescan_series_suggestions_task.delay(summary["resume_after_id"])
return summary