| """๋น๋๊ธฐ ๋ณํ ํ์ดํ๋ผ์ธ (Celery) โ ๋ฉฑ๋ฑ + lease fencing.
|
|
|
| API ์๋ฒ๋ enqueue ๋ง ํ๊ณ ์ฆ์ ์๋ตํ๋ค. ์ด ํ์คํฌ๋:
|
| - ์ด๋ฏธ ์ข
๋ฃ ์ํ(SUCCEEDED/EXPIRED/DELETED)๋ฉด ์ฆ์ return (๋ฉฑ๋ฑ)
|
| - QUEUED ์ธ task ๋ง lock ํ๋(LockContext ๋ฐ๊ธ). ์ด๋ฏธ lock ์ด ์ด์ ์์ผ๋ฉด no-op
|
| - ๋ชจ๋ ์ํ ์ฐ๊ธฐ๋ (locked_by, lock_token, lock_version) fencing ์ผ๋ก ๋ณดํธ
|
| โ reaper/๋ค๋ฅธ worker ์๊ฒ lock ์ด ๋์ด๊ฐ ๋ค ๋ฆ๊ฒ ์ด์๋ worker ๋ ์ด๋ค ๊ฒ๋ ๋ชป ์ด๋ค
|
| - ๊ฒฐ๊ณผ๋ attempt-scoped ๊ฒฝ๋ก์ ์ ์ฅํ๊ณ , SUCCEEDED ์ ์ด์ ์ฑ๊ณตํ worker ๋ง promotion
|
|
|
| ํ๋ฆ:
|
| QUEUED โ QC_CHECKING โ QC_PASSED โ AI_PROCESSING
|
| โ POST_PROCESSING โ QA_CHECKING โ SUCCEEDED
|
| ์คํจ ์ ๊ฐ ๋จ๊ณ์์ *_FAILED ๋ก ๋ถ๊ธฐ.
|
| """
|
| from __future__ import annotations
|
|
|
| import logging
|
| import os
|
|
|
| from app.db.session import SessionLocal
|
| from app.domain.states import TERMINAL_STATES, TaskStatus
|
| from app.services import artifact_repo, task_repo
|
| from app.services.comfyui_client import FaceSwapRequest, get_ai_client
|
| from app.services.photo_qc import get_photo_qc
|
| from app.services.retention import run_retention_cleanup
|
| from app.services.storage import get_storage
|
| from app.services.task_repo import LockContext
|
| from app.services.watermark import apply_ai_watermark
|
| from app.workers.celery_app import celery_app
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
| STALE = "STALE_ABORT"
|
|
|
|
|
| def attempt_result_key(task_id: str, attempt_id: str) -> str:
|
| """attempt-scoped ๊ฒฐ๊ณผ ํค. ๋ฎ์ด์ฐ๊ธฐ ๋์ ์๋๋ณ ๋ถ๋ฆฌ โ stale worker ์ถฉ๋ ๋ฐฉ์ง."""
|
| return f"results/{task_id}/attempt-{attempt_id}.png"
|
|
|
|
|
| @celery_app.task(name="process_photo", bind=True, max_retries=2)
|
| def process_photo(self, task_id: str, dispatch_version: int = 0) -> str:
|
| db = SessionLocal()
|
| worker_id = f"{self.request.hostname or 'worker'}:{self.request.id or os.getpid()}"
|
| ctx: LockContext | None = None
|
| try:
|
| task = task_repo.get_task(db, task_id)
|
| if task is None:
|
| logger.warning("task not found: %s", task_id)
|
| return "NOT_FOUND"
|
|
|
|
|
| if task.status in TERMINAL_STATES:
|
| return f"SKIP_TERMINAL:{task.status.value}"
|
|
|
|
|
| if task.dispatch_version != dispatch_version:
|
| logger.info(
|
| "stale dispatch %s: payload dv=%s != task dv=%s",
|
| task_id, dispatch_version, task.dispatch_version,
|
| )
|
| return "SKIP_STALE_DISPATCH"
|
|
|
|
|
| ctx = task_repo.acquire_lock(
|
| db, task_id, worker_id, dispatch_version=dispatch_version
|
| )
|
| if ctx is None:
|
| logger.info("no-op (lock held or not QUEUED): %s", task_id)
|
| return "SKIP_LOCKED"
|
| db.refresh(task)
|
|
|
| storage = get_storage()
|
| qc = get_photo_qc()
|
| ai = get_ai_client()
|
|
|
|
|
| if not task_repo.fenced_transition(
|
| db, ctx, TaskStatus.QC_CHECKING, expected=TaskStatus.QUEUED
|
| ):
|
| return STALE
|
| original = storage.get_bytes(task.original_key)
|
|
|
|
|
|
|
|
|
| from app.services.gemini_client import normalize_image_orientation_bytes
|
| original = normalize_image_orientation_bytes(original)
|
|
|
|
|
| in_qc = qc.check_input(original, task.original_mime or "image/jpeg")
|
| if not in_qc.passed:
|
| task_repo.fenced_transition(
|
| db, ctx, TaskStatus.QC_FAILED, expected=TaskStatus.QC_CHECKING,
|
| clear_lock=True, error_code=in_qc.code, error_detail=in_qc.detail,
|
| failure_code=in_qc.code, failure_stage="QC_CHECKING",
|
| )
|
| return TaskStatus.QC_FAILED.value
|
| if not task_repo.fenced_transition(
|
| db, ctx, TaskStatus.QC_PASSED, expected=TaskStatus.QC_CHECKING
|
| ):
|
| return STALE
|
|
|
|
|
| if not task_repo.fenced_transition(
|
| db, ctx, TaskStatus.AI_PROCESSING, expected=TaskStatus.QC_PASSED
|
| ):
|
| return STALE
|
| try:
|
| ai_res = ai.face_only_transform(
|
| FaceSwapRequest(
|
| image_bytes=original,
|
| mime=task.original_mime or "image/jpeg",
|
| face_boxes=in_qc.face_boxes,
|
| requested_gender=task.requested_gender,
|
| )
|
| )
|
| except Exception as exc:
|
| logger.exception("AI transform failed")
|
| code = getattr(exc, "code", "AI_ERROR")
|
| task_repo.fenced_transition(
|
| db, ctx, TaskStatus.AI_FAILED, expected=TaskStatus.AI_PROCESSING,
|
| clear_lock=True, error_code=code, error_detail=str(exc),
|
| failure_code=code, failure_message=str(exc),
|
| failure_stage="AI_PROCESSING",
|
| )
|
| return TaskStatus.AI_FAILED.value
|
|
|
|
|
| if not task_repo.fenced_transition(
|
| db, ctx, TaskStatus.POST_PROCESSING, expected=TaskStatus.AI_PROCESSING
|
| ):
|
| return STALE
|
| result_bytes = ai_res.image_bytes
|
|
|
|
|
| if not task_repo.fenced_transition(
|
| db, ctx, TaskStatus.QA_CHECKING, expected=TaskStatus.POST_PROCESSING
|
| ):
|
| return STALE
|
| out_qc = qc.check_output(original, result_bytes)
|
| if not out_qc.passed:
|
| task_repo.fenced_transition(
|
| db, ctx, TaskStatus.QA_FAILED, expected=TaskStatus.QA_CHECKING,
|
| clear_lock=True, error_code=out_qc.code, error_detail=out_qc.detail,
|
| failure_code=out_qc.code, failure_stage="QA_CHECKING",
|
| )
|
| return TaskStatus.QA_FAILED.value
|
|
|
|
|
|
|
| result_bytes = apply_ai_watermark(result_bytes)
|
|
|
|
|
|
|
| result_key = attempt_result_key(task.task_id, ctx.current_attempt_id)
|
| storage.put_bytes(result_key, result_bytes, "image/png")
|
| result_url = storage.public_url(result_key)
|
| artifact_repo.record_temp(db, task.task_id, ctx.current_attempt_id, result_key)
|
|
|
|
|
|
|
| if not task_repo.fenced_succeed(
|
| db, ctx, result_key=result_key, result_url=result_url
|
| ):
|
| logger.info("lost SUCCEEDED race (stale): %s", task_id)
|
| artifact_repo.mark_stale(db, task.task_id, ctx.current_attempt_id)
|
| return STALE
|
| artifact_repo.mark_promoted(db, task.task_id, ctx.current_attempt_id)
|
| return TaskStatus.SUCCEEDED.value
|
|
|
| except Exception as exc:
|
| logger.exception("pipeline crashed for %s", task_id)
|
| if ctx is not None:
|
| try:
|
| task_repo.release_lock(db, ctx)
|
| except Exception:
|
| pass
|
| raise self.retry(exc=exc, countdown=5)
|
| finally:
|
| db.close()
|
|
|
|
|
| @celery_app.task(name="retention_cleanup")
|
| def retention_cleanup() -> dict:
|
| db = SessionLocal()
|
| try:
|
| return run_retention_cleanup(db)
|
| finally:
|
| db.close()
|
|
|