"""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.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