Files
FabledCurator/backend/app/tasks/admin.py
T
bvandeusenandClaude Opus 5.5 2fc71cfe4e
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
feat: Cleanup can import files that are on disk but not in the library
A download writes its files into the artist's folder and imports them in a
later step. A run that stopped in between left complete files no record
points at, and a walk that had already marked them seen never returned for
them; on the live instance that is about 95 GB of art.

"Import files the library is missing" (Cleanup, Duplicates & leftovers)
walks every folder named for an artist, matched by slug so a differently
cased folder still matches, and lists the images, videos and archives no
image, attachment or queued import points at. Stores, sidecars, staging
files and files changed in the last day are left alone. Preview shows the
counts per artist; Import queues the same list as a library import batch,
whose tasks file the folder's post record, attach each file where it lies
with its folder's artist and source, and remove the ones the library
already holds or the import filters reject. A file whose import crashed is
kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:31:37 -05:00

488 lines
21 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.repair_discord_downloads_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 repair_discord_downloads_task(self, dry_run: bool = True) -> dict:
"""Clean re-download of the Discord files broken by the `None` naming
(#3999). dry_run (the default) returns the projection; apply deletes the
broken images and their files, makes gallery-dl forget every Discord
download, and restarts every Discord source's backfill. Defaults to the SAFE
preview because the apply deletes files. Operator-triggered only."""
from ..services.discord_repair import repair_discord_downloads
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return repair_discord_downloads(
session, images_root=IMAGES_ROOT, dry_run=dry_run,
)
@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
@celery.task(
name="backend.app.tasks.admin.reclaim_orphaned_attachments_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
# The service stops walking at its own 900s budget and reports partial, so
# these limits are the backstop for a wedged filesystem (NFS stall), not the
# expected exit. Comfortably above the budget so a normal run always returns
# its summary rather than being killed mid-walk.
soft_time_limit=1200, time_limit=1500, # 20 min / 25 min
)
def reclaim_orphaned_attachments_task(self, dry_run: bool = True) -> dict:
"""Reclaim unattributed PostAttachment rows and the store blobs nothing
references any more (#3068). dry_run (the default) returns the projection
without touching rows or files; apply deletes the orphan rows, then unlinks
every blob no surviving row references.
Defaults to the SAFE preview — unlike the other tasks here, whose apply is
reversible-ish or scoped; this one deletes files. Operator-triggered only,
never on a beat: an unattended sweep that unlinks blobs is not something to
run without someone reading the projection first."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.reclaim_orphaned_attachments(
session, images_root=IMAGES_ROOT, dry_run=dry_run,
)
@celery.task(
name="backend.app.tasks.admin.import_unimported_files_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
# One walk of the artists' folders: a stat per file, so minutes on a large
# library on network storage. The limits are the backstop for a wedged
# filesystem, not the expected exit.
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def import_unimported_files_task(self, dry_run: bool = True) -> dict:
"""Find files in the artists' folders that the library has no record of.
dry_run (the default) returns what was found, per artist, without changing
anything; apply queues them as a `library` import batch, which attaches each
file where it lies and removes the ones the library already holds or the
import filters reject."""
from ..services import library_scan
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
if dry_run:
_found, summary = library_scan.find_unimported(session, IMAGES_ROOT)
return {**summary, "batch_id": None}
return library_scan.queue_unimported(session, IMAGES_ROOT)