CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m3s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m35s
The operator chose a clean re-download over relinking in place. The ~1,600 broken files can't be relinked reliably: their message ids are gone, and their sidecars collided.
Settings → Maintenance → "Repair Discord downloads" previews, then applies:
- Deletes every image whose path is `…/discord/None/<8 digits>_None_…`. Both the folder and the name are required, because that pair is only what the bug produced. It reuses cleanup_service.delete_images for the record and file deletes.
- Sweeps the leftover collided sidecars from those folders and removes the emptied folders.
- Only then clears gallery-dl's archive rows `discord%`, excluding `discordasset_%`. Upstream keys message attachments as `discord{message_id}_{num}`. Since the broken files lost their message ids, per-source forgetting is impossible. Every pre-fix Discord download is broken, and files fetched after the fix still exist on disk, so gallery-dl's `skip` won't re-fetch them.
- Arms a fresh backfill on every Discord source.
The apply defaults to preview at both the route and the task, runs on maintenance_long, and is never on a beat. The card uses the confirm-dialog pattern of AttachmentReclaimCard.
Supporting refactors, with no behaviour change:
- gallery_dl.archive_path() is the single definition of the archive location.
- source_service.arm_backfill() is the mutation start_backfill already did, now shared with the sync repair.
Tests (tests/test_discord_repair.py):
- The archive clear leaves other platforms and Discord assets alone, and counting mutates nothing.
- Case-twin artist folders are both found.
- The folder sweep works.
- An integration run shows only the broken image goes. A correctly named Discord file and a `None` folder under Patreon survive, and only Discord sources are re-armed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
462 lines
20 KiB
Python
462 lines
20 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,
|
|
)
|