CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m34s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m26s
Build images / promote (push) Skipped
CI / integration (push) Failing after 3m5s
Three Celery tasks wrapping the 3a service, and the endpoints that drive them. Routed to `maintenance_long` alongside backups: 33k renames on NFS have no business in the quick lane where the self-healing sweeps live (the 2026-06-07 starvation). A durability bug in 3a, found by thinking about what a crash costs rather than by a failing test: `apply_run` wrote its ledger only at the end, so a worker dying at row 30,000 of 33,789 would have taken the undo information for the first 29,999 with it — and that ledger is the ONLY record of where those files came from. It now persists every 200 moves. Two things fell out of writing that: - `_persist` reassigns `run.moves`, so the loop had to snapshot the plan first rather than iterate the attribute it rewrites. - the reassignment is itself load-bearing: SQLAlchemy does not track in-place mutation of a JSONB list, so an `.append()` alone would never reach the database and the ledger would have stayed silently empty. Re-running a partially-applied plan is safe — the moved rows no longer match their `from` and refuse as "row moved since planning" — but `apply_placement` deliberately has NO autoretry: re-entering a half-applied plan should be the operator's call after reading what happened, not the queue's. Endpoints gate on run state as well as the service does, so a stray POST cannot re-apply an applied run. The list response omits `moves` (an applied whole-library run carries tens of thousands of entries); the detail endpoint includes them, because that detail IS the preview read before agreeing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
"""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,
|
|
}
|