火山引擎SMS API|celery容灾优化|生成模型引擎积分列表API

This commit is contained in:
2026-06-04 16:28:59 +08:00
parent 450e509b1a
commit 8d43d5871c
25 changed files with 1936 additions and 302 deletions
+6 -2
View File
@@ -5,7 +5,11 @@ custom named tasks are registered when workers start.
"""
try:
from app.tasks import generation_create_tasks, generation_poll_tasks, generation_download_tasks # noqa: F401
from app.tasks import ( # noqa: F401
generation_create_tasks,
generation_poll_tasks,
generation_download_tasks,
generation_recovery_tasks,
)
except Exception:
# Keep application importable even when optional Celery dependencies/config are absent.
pass
+61 -15
View File
@@ -1,16 +1,20 @@
import logging
from celery import Celery
from celery.signals import worker_process_init, worker_process_shutdown, worker_ready
from app.config import settings
from celery.signals import worker_process_init, worker_process_shutdown
from app.tasks.async_runner import run_async, close_loop
from app.models.base import engine
from app.tasks.async_runner import close_loop, run_async
logger = logging.getLogger("video_gen")
def _derive_redis_db(url: str, db_no: int) -> str:
if not url:
return url
import re
if re.search(r"/\d+$", url):
return re.sub(r"/\d+$", f"/{db_no}", url)
return url.rstrip("/") + f"/{db_no}"
@@ -37,11 +41,16 @@ if broker_url:
worker_prefetch_multiplier=1,
broker_transport_options={
"visibility_timeout": 3600,
"queue_order_strategy": "priority",
"priority_steps": list(range(10)),
"sep": ":",
},
task_routes={
"generation.chatapi_create_generation_task": {"queue": "gen_chatapi_create"},
"generation.poll_generation_task": {"queue": "gen_provider_poll"},
"generation.download_generation_result_task": {"queue": "gen_result_download"},
"generation.recover_download_tasks_once": {"queue": "gen_result_download"},
"generation.recover_generation_tasks_once": {"queue": "gen_result_download"},
"app.tasks.cleanup.*": {"queue": "default"},
},
)
@@ -50,15 +59,47 @@ else:
celery_app = None
@worker_ready.connect
def on_worker_ready(sender=None, **kwargs):
"""Celery worker 启动时做一次容灾恢复。
注意:
- 不启用 Celery beat。
- 不要求新增第四条启动命令。
- 只让 gen_result_download worker 投递恢复任务,避免三个 worker 同时重复扫描。
"""
if celery_app is None:
return
hostname = str(getattr(sender, "hostname", "") or "")
if "gen_result_download" not in hostname:
return
try:
from app.tasks.generation_recovery_tasks import (
recover_download_tasks_once,
recover_generation_tasks_once,
)
countdown = max(0, int(settings.DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS or 0))
recover_generation_tasks_once.apply_async(
countdown=countdown,
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
recover_download_tasks_once.apply_async(
countdown=countdown + 5,
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
except Exception:
logger.exception("启动容灾恢复任务投递失败")
@worker_process_init.connect
def on_worker_process_init(**kwargs):
"""
Linux prefork 子进程启动后执行。
目的:
1. 丢弃 fork 前可能继承的连接池状态。
2. 后续任务会在当前子进程自己的长期 event loop 上重新建连接池。
"""
"""Linux prefork 子进程启动后丢弃 fork 前可能继承的连接池状态。"""
try:
run_async(engine.dispose())
except Exception:
@@ -67,12 +108,17 @@ def on_worker_process_init(**kwargs):
@worker_process_shutdown.connect
def on_worker_process_shutdown(**kwargs):
"""
子进程退出前关闭连接池和 event loop。
"""
"""子进程退出前关闭连接池和 event loop。"""
try:
run_async(engine.dispose())
except Exception:
pass
try:
from app.services.celery_download_recovery_service import close_registry_redis
run_async(close_registry_redis())
except Exception:
pass
finally:
close_loop()
close_loop()
@@ -172,9 +172,9 @@ async def _run(task_id: str):
task.pipeline_stage = "result_ready"
await db.commit()
from app.tasks.generation_download_tasks import download_generation_result_task
from app.tasks.generation_download_tasks import enqueue_download_task
download_generation_result_task.delay(task.id)
await enqueue_download_task(db, task, reason="create_remote_result_ready")
return
else:
@@ -223,9 +223,9 @@ async def _run(task_id: str):
)
if task.pipeline_stage == "result_ready":
from app.tasks.generation_download_tasks import download_generation_result_task
from app.tasks.generation_download_tasks import enqueue_download_task
download_generation_result_task.delay(task.id)
await enqueue_download_task(db, task, reason="create_result_ready")
else:
from app.tasks.generation_poll_tasks import poll_generation_task
@@ -1,10 +1,19 @@
from app.tasks.async_runner import run_async
from datetime import datetime, timezone, timedelta
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.services.celery_download_recovery_service import (
build_download_active_payload,
ensure_aware_utc,
remove_download_active,
upsert_download_active,
)
from app.services.error_codes import extract_error_message
from app.services.generation_download_service import download_generation_result
from app.services.generation_log_service import log_task_event
@@ -12,52 +21,132 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
from app.services.resource_accounting_service import record_chat_task_generated_resource
from app.tasks.celery_app import celery_app
# downloading 卡住多久后允许自动恢复。
# 说明:
# - worker 在 pipeline_stage 改成 downloading 后,如果被 kill,任务可能永远停在 downloading。
# - 这里允许超过该时间的 downloading 任务重新进入下载流程。
# - 如果你的视频文件特别大,可以把这个时间调大,比如 20 * 60。
DOWNLOAD_STUCK_SECONDS = 10 * 60
DOWNLOAD_QUEUE = "gen_result_download"
DOWNLOAD_STAGE_QUEUED = "download_queued"
DOWNLOAD_STAGE_DOWNLOADING = "downloading"
DOWNLOAD_STAGE_RETRY_WAITING = "retry_waiting"
DOWNLOAD_STAGE_DONE = "done"
DOWNLOAD_STAGE_FAILED = "download_failed"
def _to_aware_utc(dt):
"""
把 datetime 统一转成 timezone-aware UTC,避免 offset-naive 和 offset-aware 比较报错。
PostgreSQL / SQLite / 不同驱动返回的 updated_at 可能有时区,也可能没有。
"""
if not dt:
def _now() -> datetime:
return datetime.now(timezone.utc)
def _task_created_date_dir(task: ChatGenerationTask) -> str:
created_at = ensure_aware_utc(getattr(task, "created_at", None)) or _now()
return created_at.strftime("%Y/%m/%d")
def _queue_timeout_at(now: datetime | None = None) -> datetime:
now = now or _now()
return now + timedelta(seconds=int(settings.DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS or 300))
def _lease_until(now: datetime | None = None) -> datetime:
now = now or _now()
return now + timedelta(seconds=int(settings.DOWNLOAD_TASK_LEASE_SECONDS or 600))
def _retry_at(attempt: int, now: datetime | None = None) -> datetime:
now = now or _now()
base = int(settings.DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS or 30)
return now + timedelta(seconds=max(1, base * max(1, attempt)))
def _is_expired(value: datetime | None, now: datetime | None = None) -> bool:
value = ensure_aware_utc(value)
if value is None:
return True
return value <= (now or _now())
def _is_already_completed(task: ChatGenerationTask) -> bool:
if task.status == "completed" or task.pipeline_stage == DOWNLOAD_STAGE_DONE:
if task.gen_type == "image" and task.image_url:
return True
if task.gen_type == "video" and task.video_url:
return True
return False
def _build_celery_task_id(task_id: str, attempt: int | None = None, reason: str | None = None) -> str:
safe_reason = (reason or "download").replace(" ", "_")[:32]
return f"download:{task_id}:{int(attempt or 0)}:{safe_reason}:{uuid.uuid4().hex[:12]}"
async def _register_active_from_task(
task: ChatGenerationTask,
*,
check_at: datetime,
priority: int,
reason: str | None = None,
) -> None:
payload = build_download_active_payload(
record_id=task.id,
celery_task_id=task.download_celery_task_id,
stage=task.pipeline_stage or "",
attempt=task.download_attempt_count or 0,
queue=DOWNLOAD_QUEUE,
priority=priority,
enqueue_at=task.download_enqueued_at,
started_at=task.download_started_at,
lease_until=task.download_lease_until,
next_retry_at=task.download_next_retry_at,
check_at=check_at,
reason=reason,
)
await upsert_download_active(record_id=task.id, payload=payload, check_at=check_at)
async def enqueue_download_task(
db: AsyncSession,
task: ChatGenerationTask,
*,
recover: bool = False,
reason: str | None = None,
countdown: int | None = None,
) -> str | None:
"""统一投递图片/视频下载任务,并同步 DB + Redis active 注册表。"""
if not task or task.generation_mode != "chatapi_async":
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
if task.status != "generating":
return None
if not task.remote_result_url:
return None
now = _now()
priority = settings.DOWNLOAD_TASK_PRIORITY_RECOVER if recover else settings.DOWNLOAD_TASK_PRIORITY_NORMAL
celery_task_id = _build_celery_task_id(
task.id,
attempt=task.download_attempt_count or task.retry_count or 0,
reason=reason or ("recover" if recover else "normal"),
)
task.pipeline_stage = DOWNLOAD_STAGE_QUEUED
task.download_celery_task_id = celery_task_id
task.download_enqueued_at = now
task.download_next_retry_at = None
if not task.download_storage_date_dir:
task.download_storage_date_dir = _task_created_date_dir(task)
await db.commit()
check_at = _queue_timeout_at(now)
await _register_active_from_task(task, check_at=check_at, priority=priority, reason=reason)
if celery_app:
download_generation_result_task.apply_async(
args=[task.id],
queue=DOWNLOAD_QUEUE,
priority=priority,
countdown=countdown,
task_id=celery_task_id,
)
return celery_task_id
def _is_recent_downloading(task: ChatGenerationTask) -> bool:
"""
判断 downloading 是否仍然是较新的下载任务。
返回 True
- 说明可能有另一个 worker 刚进入下载,不要重复下载。
返回 False
- 说明 downloading 已经超过 DOWNLOAD_STUCK_SECONDS,认为可能卡死,可以恢复。
"""
updated_at = _to_aware_utc(getattr(task, "updated_at", None))
if not updated_at:
return False
return datetime.now(timezone.utc) - updated_at < timedelta(seconds=DOWNLOAD_STUCK_SECONDS)
async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:
"""
rollback 后重新查询任务对象。
说明:
- SQLAlchemy rollback 后,当前 ORM 对象可能过期。
- 继续访问旧 task 有概率触发异步懒加载异常。
"""
async def _reload_task(db: AsyncSession, task_id: str) -> ChatGenerationTask | None:
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id,
@@ -67,6 +156,110 @@ async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:
return result.scalar_one_or_none()
async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> bool:
now = _now()
if not task or task.generation_mode != "chatapi_async":
return False
if task.status != "generating":
return False
if _is_already_completed(task):
return False
if not task.remote_result_url:
return False
stage = task.pipeline_stage
if stage == DOWNLOAD_STAGE_DOWNLOADING:
if not _is_expired(task.download_lease_until, now):
return False
await log_task_event(
task,
event_type="DOWNLOAD_STUCK_RECOVER",
message=f"downloading lease 已过期,重新抢占下载。lease_until={task.download_lease_until}",
)
elif stage == DOWNLOAD_STAGE_RETRY_WAITING:
if not _is_expired(task.download_next_retry_at, now):
return False
elif stage in (DOWNLOAD_STAGE_QUEUED, "result_ready"):
pass
else:
return False
old_stage = stage
task.pipeline_stage = DOWNLOAD_STAGE_DOWNLOADING
task.download_started_at = now
task.download_lease_until = _lease_until(now)
task.download_next_retry_at = None
task.download_attempt_count = int(task.download_attempt_count or 0) + 1
task.retry_count = task.download_attempt_count
if not task.download_storage_date_dir:
task.download_storage_date_dir = _task_created_date_dir(task)
await db.commit()
await _register_active_from_task(
task,
check_at=task.download_lease_until,
priority=settings.DOWNLOAD_TASK_PRIORITY_NORMAL,
reason="claim_download_lease",
)
await log_task_event(
task,
event_type="DOWNLOAD_START",
from_stage=old_stage,
to_stage=DOWNLOAD_STAGE_DOWNLOADING,
detail={
"attempt": task.download_attempt_count,
"lease_until": task.download_lease_until,
"download_celery_task_id": task.download_celery_task_id,
},
)
return True
async def _mark_retry_waiting(db: AsyncSession, task: ChatGenerationTask, exc: Exception) -> datetime:
now = _now()
attempt = int(task.download_attempt_count or task.retry_count or 0)
next_retry_at = _retry_at(attempt, now)
error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc)
retry_celery_task_id = _build_celery_task_id(task.id, attempt=attempt, reason="retry_waiting")
task.pipeline_stage = DOWNLOAD_STAGE_RETRY_WAITING
task.download_celery_task_id = retry_celery_task_id
task.download_next_retry_at = next_retry_at
task.download_lease_until = None
task.download_last_error = error_message
task.retry_count = attempt
await db.commit()
await _register_active_from_task(
task,
check_at=next_retry_at,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
reason="download_retry_waiting",
)
await log_task_event(
task,
event_type="DOWNLOAD_RETRY_WAITING",
message=error_message,
to_stage=DOWNLOAD_STAGE_RETRY_WAITING,
detail={
"attempt": attempt,
"next_retry_at": next_retry_at,
"download_celery_task_id": retry_celery_task_id,
},
)
return next_retry_at
def _should_final_fail(task: ChatGenerationTask) -> bool:
return int(task.download_attempt_count or task.retry_count or 0) >= int(settings.DOWNLOAD_TASK_MAX_ATTEMPTS or 3)
async def _run(task_id: str):
async with async_session() as db:
result = await db.execute(
@@ -76,46 +269,20 @@ async def _run(task_id: str):
).with_for_update().limit(1)
)
task = result.scalar_one_or_none()
if not task or task.generation_mode != "chatapi_async":
if not task:
return
if task.status != "generating":
return
# 关键修改 3
# 原来只允许 result_ready 进入下载。
# 现在允许 downloading 恢复,但只有“卡住超过 DOWNLOAD_STUCK_SECONDS”的 downloading 才继续。
if task.pipeline_stage == "downloading":
if _is_recent_downloading(task):
# downloading 很新,说明可能有 worker 正在下载,直接跳过,避免并发重复下载。
return
# downloading 已经很久没更新,认为 worker 可能挂了,允许恢复下载。
await log_task_event(
task,
event_type="DOWNLOAD_STUCK_RECOVER",
message=f"downloading 超过 {DOWNLOAD_STUCK_SECONDS} 秒,重新进入下载流程",
)
elif task.pipeline_stage != "result_ready":
claimed = await _claim_download_lease(db, task)
if not claimed:
return
try:
old_stage = task.pipeline_stage
# 无论从 result_ready 进入,还是从 stuck downloading 恢复,都重新标记为 downloading。
task.pipeline_stage = "downloading"
await db.commit()
await log_task_event(
task,
event_type="DOWNLOAD_START",
from_stage=old_stage,
to_stage="downloading",
)
downloaded = await download_generation_result(task)
task = await _reload_task(db, task_id)
if not task:
return
if task.gen_type == "image":
task.image_url = downloaded.url
else:
@@ -123,9 +290,12 @@ async def _run(task_id: str):
task.video_cover_url = downloaded.cover_url
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = datetime.now(timezone.utc)
task.pipeline_stage = DOWNLOAD_STAGE_DONE
task.generated_at = _now()
task.retry_count = 0
task.download_lease_until = None
task.download_next_retry_at = None
task.download_last_error = None
await record_chat_task_generated_resource(
db,
@@ -138,22 +308,22 @@ async def _run(task_id: str):
)
await db.commit()
await remove_download_active(task.id)
await log_task_event(
task,
event_type="DOWNLOAD_SUCCESS",
to_status="completed",
to_stage="done",
to_stage=DOWNLOAD_STAGE_DONE,
detail={
"resource_url": downloaded.url,
"video_cover_url": downloaded.cover_url,
"file_size_bytes": downloaded.file_size_bytes,
"download_attempt_count": task.download_attempt_count,
},
)
except Exception as exc:
# 关键修改 2
# 异常后先 rollback,再重新查询 task,不继续使用 rollback 前的旧 ORM 对象。
try:
await db.rollback()
except Exception:
@@ -163,33 +333,42 @@ async def _run(task_id: str):
if not task:
return
task.retry_count = (task.retry_count or 0) + 1
if task.retry_count > 3:
if _should_final_fail(task):
error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc)
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage="download_failed",
pipeline_stage=DOWNLOAD_STAGE_FAILED,
)
task.download_last_error = error_message
task.download_lease_until = None
task.download_next_retry_at = None
await db.commit()
await remove_download_active(task.id)
await log_task_event(
task,
event_type="DOWNLOAD_FAILED",
message=task.error_message,
detail={
"download_attempt_count": task.download_attempt_count,
"max_attempts": settings.DOWNLOAD_TASK_MAX_ATTEMPTS,
},
)
else:
# 下载失败但未超过重试次数,改回 result_ready,等待下一次下载。
# 这样不会卡死在 downloading。
task.pipeline_stage = "result_ready"
await db.commit()
next_retry_at = await _mark_retry_waiting(db, task, exc)
download_generation_result_task.apply_async(
args=[task.id],
countdown=30 * task.retry_count,
)
if celery_app:
delay_seconds = max(1, int((next_retry_at - _now()).total_seconds()))
download_generation_result_task.apply_async(
args=[task.id],
queue=DOWNLOAD_QUEUE,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
countdown=delay_seconds,
task_id=task.download_celery_task_id,
)
if celery_app:
@@ -140,8 +140,9 @@ async def _run(task_id: str):
await log_task_event(task, event_type="POLL_SUCCESS", to_stage="result_ready")
from app.tasks.generation_download_tasks import download_generation_result_task
download_generation_result_task.delay(task.id)
from app.tasks.generation_download_tasks import enqueue_download_task
await enqueue_download_task(db, task, reason="poll_success_result_ready")
return
if _is_failed(status):
@@ -0,0 +1,46 @@
# app/tasks/generation_recovery_tasks.py
from __future__ import annotations
from typing import Any, Dict
from app.models.base import async_session
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
async def _run_download_once() -> Dict[str, Any]:
from app.services.generation_recovery_service import recover_download_tasks_once
async with async_session() as db:
return await recover_download_tasks_once(db)
async def _run_generation_once() -> Dict[str, Any]:
from app.services.generation_recovery_service import recover_generation_tasks_once
async with async_session() as db:
return await recover_generation_tasks_once(db)
if celery_app:
@celery_app.task(name="generation.recover_download_tasks_once")
def recover_download_tasks_once() -> Dict[str, Any]:
return run_async(_run_download_once())
@celery_app.task(name="generation.recover_generation_tasks_once")
def recover_generation_tasks_once() -> Dict[str, Any]:
return run_async(_run_generation_once())
else:
class _DisabledTask:
def delay(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
def apply_async(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
recover_download_tasks_once = _DisabledTask()
recover_generation_tasks_once = _DisabledTask()