"""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, ) # Wall-clock budget for the gated-preview re-walk: stop walking new sources past # this and report partial (operator re-runs to finish). Sits under the soft limit # so the task returns its summary cleanly instead of being SIGKILLed mid-walk. _GATED_PURGE_BUDGET_SECONDS = 1500 @celery.task( name="backend.app.tasks.admin.purge_gated_previews_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 purge_gated_previews_task(self, dry_run: bool = True) -> dict: """Cleanup (#874 follow-up): purge blurred locked-preview images grabbed from tier-gated Patreon posts before the ingester fix. Re-walks every enabled Patreon source (read-only, no downloads) to re-derive which posts are gated NOW and the blurred filehashes Patreon serves for them, then matches by content hash and (unless dry_run) deletes only those exact files — real content downloaded when access existed has a different hash and is provably spared. dry_run=True returns the projection (gated posts / matched images / reclaimable bytes / unverifiable kept) WITHOUT deleting; dry_run=false applies it. The summary lands in task_run.metadata (FC-3i) for the Maintenance card. Time-boxed across sources: a `partial` result means re-run to finish (idempotent — a re-walk re-derives the same matches and already-deleted files stay deleted).""" import asyncio import time as _time from sqlalchemy import select as _select from ..models import Source from ..services.credential_crypto import CredentialCrypto from ..services.credential_service import CredentialService from ..services.patreon_client import PatreonClient from ..services.patreon_resolver import resolve_campaign_id_for_source from ._async_session import async_session_factory key_path = IMAGES_ROOT / "secrets" / "credential_key.b64" async def _run() -> dict: async_factory, async_engine = async_session_factory() SessionLocal = _sync_session_factory() try: async with async_factory() as async_session: cred = CredentialService(async_session, CredentialCrypto(key_path)) cookies_obj = await cred.get_cookies_path("patreon") cookies_path = str(cookies_obj) if cookies_obj else None with SessionLocal() as session: sources = session.execute( _select(Source.id, Source.url, Source.config_overrides) .where(Source.platform == "patreon", Source.enabled.is_(True)) ).all() gated_map: dict[int, dict] = {} scanned = 0 partial = False loop = asyncio.get_running_loop() start = _time.monotonic() for sid, url, overrides in sources: if _time.monotonic() - start >= _GATED_PURGE_BUDGET_SECONDS: partial = True break campaign_id, _resolved = await resolve_campaign_id_for_source( url, cookies_path, overrides or {} ) if not campaign_id: log.warning( "gated-purge: couldn't resolve campaign for source %s (%s) " "— skipping", sid, url, ) continue client = PatreonClient(cookies_path) try: gated = await loop.run_in_executor( None, cleanup_service.collect_gated_previews, client, campaign_id ) except Exception as exc: # one bad feed must not strand the sweep log.warning("gated-purge walk failed for source %s: %s", sid, exc) continue scanned += 1 if gated: gated_map[int(sid)] = gated with SessionLocal() as session: summary = cleanup_service.purge_gated_previews( session, gated_map=gated_map, images_root=IMAGES_ROOT, dry_run=dry_run, ) summary["sources_scanned"] = scanned summary["sources_total"] = len(sources) summary["partial"] = partial return summary finally: await async_engine.dispose() return asyncio.run(_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