Files
FabledCurator/backend/app/tasks/admin.py
T
bvandeusenandClaude Opus 5 2e0f8f8c61
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m47s
feat(cleanup): reclaim orphaned attachments — rows and store blobs (#3068)
PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or
artist left the row behind rather than taking it. Nothing ever pruned those
rows, and nothing in the repo had ever unlinked a file under the attachment
store — so both rows and bytes accumulated permanently, invisible to every
existing diagnostic.

Why a disk->DB reconciliation rather than a row sweep: the store is
sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row
does not free its blob, and since the artist cascade (#3066) now deletes
its attachment rows outright, a freed blob has no DB pointer left to find
it by. Walking the store and asking "does any row still reference this
sha?" catches orphans from every cause, including ones no future delete
path will think to report.

Preview and apply share `_orphan_attachment_conditions` (rule 93). The
dry-run derives its surviving-sha set by NEGATING that same predicate, so
it is honest about blobs the delete would free rather than counting them as
still-referenced — the one place this was easy to get backwards, so it has
its own parity test.

Guards, each with a reason:
- A blob is written before its row commits, so a just-stored file legitimately
  has no referencing row. Files under 6h are never judged — same guard and
  reasoning as ORPHAN_TEMP_MIN_AGE_HOURS.
- `.partial` staging files belong to cleanup_orphaned_temp_files; skipped
  rather than raced.
- The sha is parsed as the first 64 chars, not via Path.stem: store() takes
  the extension from the source filename, and a URL-encoded basename yields a
  multi-dot suffix that would make stem eat part of the sha.
- A 900s walk budget reports partial=True instead of running to the task's
  hard limit (rule 89).
- TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without
  it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare
  5-min default — the #883 failure class; its invariant test is mirrored here.

Defaults to the safe preview at both the task and the route, unlike the other
maintenance triggers: this apply unlinks files. Operator-triggered only,
never on a beat.

Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates &
leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so
a run survives navigating away. Surfaces files_failed and partial explicitly,
since both change what the numbers mean.

Also promotes humanBytes to utils/bytes.js — it was byte-identical in
VideoDedupCard and GatedPurgeCard and this card would have been the third
copy. The three divergent `formatBytes` helpers are deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:37:24 -04:00

440 lines
19 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,
)
# 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,
)