f4f49d407e
The constant + comment landed BETWEEN @celery.task(...) and the function def, which is a syntax error that broke the whole tasks.admin import (cascaded to lint E999 + every backend/integration test). Move it above the decorator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.9 KiB
Python
127 lines
4.9 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.exc import DBAPIError, OperationalError
|
|
|
|
from ..celery_app import celery
|
|
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,
|
|
)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
@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) -> 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."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return cleanup_service.reextract_archive_attachments(
|
|
session, images_root=IMAGES_ROOT,
|
|
)
|
|
|
|
|
|
# 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:
|
|
async_factory, async_engine = async_session_factory()
|
|
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
|