leechard / app /services /dispatch.py
nenae18's picture
Deploy LeeChard
5d3c2a9 verified
Raw
History Blame Contribute Delete
6.73 kB
"""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()
)
# 1) ์ ๊ฒฉ์„ฑ ํŒ์ •. ์ฝ๊ธฐ ํŠธ๋žœ์žญ์…˜์€ ๋‹ซ๋Š”๋‹ค(enqueue ๋Š” ๋ธŒ๋กœ์ปค I/O โ€” DB ๋ฝ ๋ฏธ๋ณด์œ ).
# publish ์ง์ „ task ์˜ ํ˜„์žฌ dispatch_version ์„ ์žฌํ™•์ธ:
# event.dispatch_version != task.dispatch_version โ†’ stale โ†’ STALE ๋กœ ๋งˆํ‚น ํ›„ skip.
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()
# 2) enqueue(payload ์— dispatch_version) + ์งง์€ ํŠธ๋žœ์žญ์…˜์œผ๋กœ published ๋งˆํ‚น.
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:
# ๊ฒŒ์ดํŠธ: lock ๋งŒ๋ฃŒ + next_retry ๋„๋‹ฌ
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