"""운영자(admin) 서비스 — dead-letter 수동 재처리. 원본이 이미 삭제된 task 는 재처리할 수 없다(재촬영 필요). 그 외에는 task 를 새 dispatch 세대로 초기화해 QUEUED 로 되돌리고 outbox 이벤트 + 감사 로그를 남긴다. """ from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import update from sqlalchemy.orm import Session from app.domain.states import TaskStatus from app.models.photo_task import PhotoTask from app.models.task_event import TaskEvent from app.services.storage import StorageService, get_storage from app.services.task_repo import ensure_dispatch_event def _utcnow() -> datetime: return datetime.now(timezone.utc) def _original_available(task: PhotoTask, storage: StorageService) -> bool: if task.original_deleted_at is not None: return False if not task.original_key: return False return storage.exists(task.original_key) def manual_retry_dead_letter( db: Session, task_id: str, *, actor: str, reason: str | None = None, storage: StorageService | None = None, ) -> dict: """dead-letter task 를 수동 재처리. 반환 dict 의 status 로 결과 구분. - {"status": "retake_required", "error_code": "original_not_available_for_retry"} - {"status": "requeued", "task_id", "retry_generation"} """ storage = storage or get_storage() task = db.get(PhotoTask, task_id) if task is None: return {"status": "not_found", "task_id": task_id} if task.dead_lettered_at is None: return {"status": "not_dead_lettered", "task_id": task_id} if not _original_available(task, storage): return { "status": "retake_required", "task_id": task_id, "error_code": "original_not_available_for_retry", } old_status = task.status now = _utcnow() new_generation = task.retry_generation + 1 new_dispatch_version = task.dispatch_version + 1 # lock_version 은 reset 하지 않음 # 새 dispatch 세대로 전면 초기화 (core UPDATE) db.execute( update(PhotoTask) .where(PhotoTask.task_id == task_id) .values( status=TaskStatus.QUEUED, attempt_count=0, dead_lettered_at=None, failure_code=None, failure_message=None, failure_stage=None, error_code=None, error_detail=None, locked_by=None, locked_until=None, lock_token=None, current_attempt_id=None, celery_job_id=None, next_retry_at=now, retry_generation=new_generation, manual_retry_count=PhotoTask.manual_retry_count + 1, dispatch_version=PhotoTask.dispatch_version + 1, updated_at=now, ) .execution_options(synchronize_session=False) ) # 재dispatch 용 outbox(새 dispatch_version dedupe_key, 멱등) + 감사 로그 ensure_dispatch_event( db, task_id, new_dispatch_version, next_retry_at=now, rearm_published=True ) db.add( TaskEvent( task_id=task_id, actor=actor, reason=reason, old_status=old_status.value if hasattr(old_status, "value") else str(old_status), new_status=TaskStatus.QUEUED.value, retry_generation=new_generation, dispatch_version=new_dispatch_version, ) ) db.commit() return { "status": "requeued", "task_id": task_id, "retry_generation": new_generation, "dispatch_version": new_dispatch_version, }