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