| """Enqueue ๋์คํจ์น + ์คํจ ๋ณต๊ตฌ (next_retry_at ๊ฒ์ดํธ ์กด์ค). |
| |
| - relay_outbox: ๋ฐํ ๋์ outbox ์ด๋ฒคํธ๋ฅผ enqueue. next_retry_at ๊ณผ task ์ ๊ฒฉ์ฑ์ ํ์ธ. |
| - repair_stuck_queued: ์ง์ enqueue ํ์ง ์๋๋ค. QUEUED ์ธ๋ฐ ๋ฏธ๋ฐํ outbox ๊ฐ ์๋ task ์ |
| outbox ์ด๋ฒคํธ๋ง ๋ณด์ถฉ(next_retry_at ์น๊ณ). ์ค์ dispatch ๋ relay_outbox ๊ฐ ๋ด๋น. |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| from collections.abc import Callable |
| from datetime import datetime, timezone |
| from typing import Any |
|
|
| from sqlalchemy import update |
| from sqlalchemy.orm import Session |
|
|
| from app.domain.states import TaskStatus |
| from app.models.outbox import OutboxEvent |
| from app.models.photo_task import PhotoTask |
| from app.services import task_repo |
|
|
| logger = logging.getLogger(__name__) |
|
|
| EnqueueFn = Callable[[str, int], Any] |
|
|
|
|
| def _utcnow() -> datetime: |
| return datetime.now(timezone.utc) |
|
|
|
|
| def _aware(dt: datetime | None) -> datetime | None: |
| """sqlite ๋ tz ๋ฅผ ์์ naive ๋ฅผ ๋๋ ค์ค๋ค โ UTC ๋ก ๊ฐ์ฃผํด ๋น๊ต ์์ ์ฑ ํ๋ณด.""" |
| if dt is not None and dt.tzinfo is None: |
| return dt.replace(tzinfo=timezone.utc) |
| return dt |
|
|
|
|
| def default_enqueue(task_id: str, dispatch_version: int = 0) -> Any: |
| """์ค์ Celery enqueue. payload ์ dispatch_version ์ ์ค์ด stale dispatch ๋ฅผ ์ฐจ๋จ.""" |
| from app.workers.tasks import process_photo |
|
|
| return process_photo.delay(task_id, dispatch_version) |
|
|
|
|
| def _job_id(async_result: Any) -> str | None: |
| return getattr(async_result, "id", None) |
|
|
|
|
| def mark_published(db: Session, task_id: str) -> None: |
| """ํด๋น task ์ ๋ฏธ๋ฐํ outbox ์ด๋ฒคํธ๋ฅผ ๋ฐํ ์๋ฃ๋ก ๋งํน(upload ์ฑ๊ณต ์งํ).""" |
| now = _utcnow() |
| events = ( |
| db.query(OutboxEvent) |
| .filter(OutboxEvent.task_id == task_id, OutboxEvent.published.is_(False)) |
| .all() |
| ) |
| for event in events: |
| event.published = True |
| event.status = "PUBLISHED" |
| event.published_at = now |
| event.attempts += 1 |
| if events: |
| db.commit() |
|
|
|
|
| def _task_dispatchable(task: PhotoTask | None, now: datetime) -> bool: |
| """photo_task ๊ฐ ์ง๊ธ dispatch ๊ฐ๋ฅํ ์ํ์ธ์ง(์ฌ์๋ ๊ฒ์ดํธ ํฌํจ).""" |
| if task is None: |
| return False |
| if task.status != TaskStatus.QUEUED: |
| return False |
| if task.dead_lettered_at is not None: |
| return False |
| locked_until = _aware(task.locked_until) |
| if locked_until is not None and locked_until >= now: |
| return False |
| next_retry_at = _aware(task.next_retry_at) |
| if next_retry_at is not None and next_retry_at > now: |
| return False |
| return True |
|
|
|
|
| def relay_outbox( |
| db: Session, |
| *, |
| enqueue: EnqueueFn = default_enqueue, |
| limit: int = 100, |
| now: datetime | None = None, |
| ) -> int: |
| """๋ฏธ๋ฐํ outbox ์ด๋ฒคํธ๋ฅผ enqueue. ๊ฒ์ดํธ ๋ฏธํต๊ณผ ์ด๋ฒคํธ๋ ๊ทธ๋๋ก ๋จ๊ธด๋ค. |
| |
| ์ด๋ฒคํธ ๊ฒ์ดํธ: published=False AND (next_retry_at IS NULL OR <= now) |
| task ๊ฒ์ดํธ: _task_dispatchable() (QUEUED, dead-letter ์๋, lock ๋ง๋ฃ, next_retry ๋๋ฌ) |
| """ |
| now = now or _utcnow() |
| events = ( |
| db.query(OutboxEvent) |
| .filter( |
| OutboxEvent.status.in_(("PENDING", "FAILED")), |
| OutboxEvent.published.is_(False), |
| (OutboxEvent.next_retry_at.is_(None)) |
| | (OutboxEvent.next_retry_at <= now), |
| ) |
| .order_by(OutboxEvent.created_at) |
| .limit(limit) |
| .all() |
| ) |
| |
| |
| |
| eligible = [] |
| stale_ids = [] |
| for ev in events: |
| task = db.get(PhotoTask, ev.task_id) |
| if task is not None and ev.dispatch_version != task.dispatch_version: |
| stale_ids.append(ev.id) |
| logger.info( |
| "stale_dispatch_ignored: event=%s task=%s event_dv=%s task_dv=%s", |
| ev.id, ev.task_id, ev.dispatch_version, task.dispatch_version, |
| ) |
| continue |
| if _task_dispatchable(task, now): |
| eligible.append((ev.id, ev.task_id, ev.attempts, task.dispatch_version)) |
| for ev_id in stale_ids: |
| db.execute( |
| update(OutboxEvent) |
| .where(OutboxEvent.id == ev_id) |
| .values(status="STALE", last_error="stale_dispatch_ignored") |
| .execution_options(synchronize_session=False) |
| ) |
| db.commit() |
|
|
| |
| published = 0 |
| for ev_id, task_id, attempts, dversion in eligible: |
| async_result = enqueue(task_id, dversion) |
| db.execute( |
| update(OutboxEvent) |
| .where(OutboxEvent.id == ev_id) |
| .values( |
| published=True, |
| status="PUBLISHED", |
| published_at=_utcnow(), |
| attempts=attempts + 1, |
| ) |
| .execution_options(synchronize_session=False) |
| ) |
| db.execute( |
| update(PhotoTask) |
| .where( |
| PhotoTask.task_id == task_id, |
| PhotoTask.celery_job_id.is_(None), |
| ) |
| .values(celery_job_id=_job_id(async_result)) |
| .execution_options(synchronize_session=False) |
| ) |
| db.commit() |
| published += 1 |
| return published |
|
|
|
|
| def repair_stuck_queued(db: Session, *, limit: int = 100, now: datetime | None = None) -> int: |
| """dispatch ๊ฐ๋ฅํ๋ฐ ํ์ฌ dispatch_version ์ outbox ๊ฐ ์๋ task ์ ์ด๋ฒคํธ ๋ณด์ถฉ. |
| |
| ์ง์ enqueue ํ์ง ์๋๋ค. ๋ฉฑ๋ฑ helper(ensure_dispatch_event, rearm_published=False)๋ก |
| ๊ฐ์ dispatch_version ์ค๋ณต ์์ฑ์ ๋ง๋๋ค. next_retry_at ์ task ๊ฐ์ ์น๊ณ. |
| ๋ฐํ: ์๋ก ๋ง๋ (=created) outbox ์ด๋ฒคํธ ์. |
| """ |
| now = now or _utcnow() |
| tasks = ( |
| db.query(PhotoTask) |
| .filter( |
| PhotoTask.status == TaskStatus.QUEUED, |
| PhotoTask.dead_lettered_at.is_(None), |
| ) |
| .limit(limit) |
| .all() |
| ) |
| created = 0 |
| for task in tasks: |
| |
| if not _task_dispatchable(task, now): |
| continue |
| outcome = task_repo.ensure_dispatch_event( |
| db, |
| task.task_id, |
| task.dispatch_version, |
| next_retry_at=task.next_retry_at, |
| rearm_published=False, |
| ) |
| if outcome == "created": |
| created += 1 |
| db.commit() |
| return created |
|
|