"""Placement reconciler tasks — plan, apply, revert (milestone #421). The service (`services.library_layout`) holds the decisions; this module is only the async wrapper, matching `tasks.library_audit`: run on the maintenance queue, mark the run `error` with a traceback if anything escapes, and return a small summary dict so eager-mode tests can assert on it. Applying is a long run — 33,789 renames on the operator's library at the time of writing — so `apply_placement` persists its ledger in chunks rather than at the end. That ledger is the only record of where each file came from, and a worker that dies two thirds of the way through must not take the undo information for the first two thirds with it. """ import logging import traceback from datetime import UTC, datetime from pathlib import Path from sqlalchemy.exc import DBAPIError, OperationalError from ..celery_app import celery from ..models import LibraryPlacementRun from ..services import library_layout from ._sync_engine import sync_session_factory as _sync_session_factory log = logging.getLogger(__name__) IMAGES_ROOT = Path("/images") # Commit the ledger every this many moves. Small enough that a crash loses # seconds of work, large enough not to make a COMMIT per rename. _APPLY_CHUNK = 200 def _fail(session, run_id: int, message: str) -> None: run = session.get(LibraryPlacementRun, run_id) if run is not None: run.status = "error" run.error = message run.finished_at = datetime.now(UTC) session.commit() @celery.task( name="backend.app.tasks.library_placement.plan_placement", autoretry_for=(OperationalError, DBAPIError), retry_backoff=5, retry_backoff_max=60, retry_jitter=True, max_retries=3, soft_time_limit=900, time_limit=1000, ) def plan_placement(artist_id: int | None = None) -> dict: """Build a move plan and leave it `ready` for the operator to read. Reads rows and stats destinations; moves nothing. """ SessionLocal = _sync_session_factory() with SessionLocal() as session: run = library_layout.plan_placement( session, IMAGES_ROOT, artist_id=artist_id, ) session.commit() return { "run_id": run.id, "status": run.status, "planned_count": run.planned_count, } @celery.task( name="backend.app.tasks.library_placement.apply_placement", soft_time_limit=7200, time_limit=7500, ) def apply_placement(run_id: int) -> dict: """Execute a `ready` run's stored plan. Renames files and rewrites rows. No autoretry: a retry would re-enter a half-applied plan on a schedule nobody asked for. Re-running IS safe (the applied rows refuse as "row moved since planning"), but that should be the operator's decision after reading what happened, not the queue's. """ SessionLocal = _sync_session_factory() with SessionLocal() as session: run = session.get(LibraryPlacementRun, run_id) if run is None: return {"run_id": run_id, "status": "missing"} if run.status != "ready": return {"run_id": run_id, "status": run.status, "skipped": True} try: library_layout.apply_run(session, run, chunk=_APPLY_CHUNK) session.commit() except Exception: log.exception("placement apply failed for run %s", run_id) session.rollback() _fail(session, run_id, traceback.format_exc()) return {"run_id": run_id, "status": "error"} return { "run_id": run_id, "status": run.status, "moved": run.moved_count, "refused": run.refused_count, } @celery.task( name="backend.app.tasks.library_placement.revert_placement", soft_time_limit=7200, time_limit=7500, ) def revert_placement(run_id: int) -> dict: """Put an applied run's files back where they came from.""" SessionLocal = _sync_session_factory() with SessionLocal() as session: run = session.get(LibraryPlacementRun, run_id) if run is None: return {"run_id": run_id, "status": "missing"} if run.status != "applied": return {"run_id": run_id, "status": run.status, "skipped": True} try: library_layout.revert_run(session, run) session.commit() except Exception: log.exception("placement revert failed for run %s", run_id) session.rollback() _fail(session, run_id, traceback.format_exc()) return {"run_id": run_id, "status": "error"} return { "run_id": run_id, "status": run.status, "refused": run.refused_count, }