leechard / app /workers /tasks.py
nenae18's picture
Deploy LeeChard
c6bf392 verified
Raw
History Blame Contribute Delete
8.57 kB
"""๋น„๋™๊ธฐ ๋ณ€ํ™˜ ํŒŒ์ดํ”„๋ผ์ธ (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}"
# dispatch fencing: manual retry ๋“ฑ์œผ๋กœ ์„ธ๋Œ€๊ฐ€ ์˜ฌ๋ผ๊ฐ„ old dispatch ๋Š” no-op.
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"
# lock: QUEUED + ๊ฐ™์€ dispatch_version ์ธ task ๋งŒ ํš๋“(fencing context ๋ฐœ๊ธ‰).
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()
# 1) QUEUED -> QC_CHECKING
if not task_repo.fenced_transition(
db, ctx, TaskStatus.QC_CHECKING, expected=TaskStatus.QUEUED
):
return STALE
original = storage.get_bytes(task.original_key)
# ํฐ ์‚ฌ์ง„์€ EXIF ๋ฐฉํ–ฅ ํƒœ๊ทธ๋กœ ํ”ฝ์…€์ด ๋ˆ•ํ˜€์ ธ ์ €์žฅ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ๋‹ค. AI ์—”์ง„์€
# ๋‚ด๋ถ€์—์„œ ๋ฐฉํ–ฅ์„ ์ •๊ทœํ™”(upright)ํ•ด ๊ฒฐ๊ณผ๋ฅผ ๋‚ด๋ฏ€๋กœ, ์—ฌ๊ธฐ์„œ ์›๋ณธ๋„ ๋ฏธ๋ฆฌ ์ •๊ทœํ™”ํ•˜์ง€
# ์•Š์œผ๋ฉด QC/AI/QA/์ถœ๋ ฅ์ด ์„œ๋กœ ๋‹ค๋ฅธ ๋ฐฉํ–ฅ(์ „์น˜๋œ ํฌ๊ธฐ)์„ ๋ณด๊ฒŒ ๋˜์–ด ์ •์ƒ ๊ฒฐ๊ณผ๊ฐ€
# OUTPUT_SIZE_MISMATCH ๋กœ ์ž˜๋ชป ๊ฑธ๋ฆฐ๋‹ค. ์ ์žฌ ์งํ›„ ํ•œ ๋ฒˆ๋งŒ ์ •๊ทœํ™”ํ•ด ๋ชจ๋‘ ์ผ์น˜์‹œํ‚จ๋‹ค.
from app.services.gemini_client import normalize_image_orientation_bytes
original = normalize_image_orientation_bytes(original)
# 2) ์ž…๋ ฅ QC
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
# 3) AI ๋ณ€ํ™˜ (์–ผ๊ตด ์˜์—ญ๋งŒ)
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: # noqa: BLE001
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
# 4) ํ›„์ฒ˜๋ฆฌ (๋ฆฌ์‚ฌ์ด์ฆˆ/์ƒ‰๋ณด์ • ์ž๋ฆฌ) โ€” mock ํ†ต๊ณผ
if not task_repo.fenced_transition(
db, ctx, TaskStatus.POST_PROCESSING, expected=TaskStatus.AI_PROCESSING
):
return STALE
result_bytes = ai_res.image_bytes
# 5) ๊ฒฐ๊ณผ QA โ€” ์–ผ๊ตด ์™ธ ์˜์—ญ ๋ณด์กด ๊ฒ€์ฆ
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
# 6) AI ๊ณ ์ง€ ์›Œํ„ฐ๋งˆํฌ + ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (stub/real ๋™์ผ). ๊ณต์œ /๋‹ค์šด๋กœ๋“œ๋˜๋Š”
# ์ตœ์ข… ์ด๋ฏธ์ง€์—๋งŒ ์ ์šฉํ•˜๋ฉฐ, ์–ผ๊ตด/ํ—ค์–ด๋ฅผ ๊ฐ€๋ฆฌ์ง€ ์•Š๋Š” ์—ฌ๋ฐฑ ๋ชจ์„œ๋ฆฌ์— ํ‘œ์‹œํ•œ๋‹ค.
result_bytes = apply_ai_watermark(result_bytes)
# 7) attempt-scoped ๊ฒฝ๋กœ์— ์ €์žฅ + artifact TEMP ๊ธฐ๋ก
# (stale worker ๋„ ์ž๊ธฐ attempt ๊ฒฝ๋กœ์—๋งŒ ์“ด๋‹ค โ†’ ์ถฉ๋Œ ์—†์Œ)
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)
# 7) SUCCEEDED ์ „์ด + result_key promotion (์›์ž์ , fencing).
# ์„ฑ๊ณตํ•œ worker ๋งŒ artifact ๋ฅผ PROMOTED ๋กœ ์Šน๊ฒฉํ•˜๊ณ , stale ์€ STALE ๋กœ ํ‘œ์‹œ.
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: # noqa: BLE001
logger.exception("pipeline crashed for %s", task_id)
if ctx is not None:
try:
task_repo.release_lock(db, ctx)
except Exception: # noqa: BLE001
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()