fbf8844f25
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2s
AI Code Review / AI Code Review (pull_request) Failing after 3s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 7m15s
CI/CD Pipeline / Validate - Style (push) Successful in 7m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m57s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""GPU MuseTalk 口型同步服务 — 反向轮询模式.
|
||
|
||
职责:
|
||
1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队;
|
||
2. Worker 心跳注册(register):登记/刷新 worker 状态;
|
||
3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL;
|
||
4. Worker 上报结果(report_result):标记 done/failed,失败可重试;
|
||
5. 业务侧查询状态(get_status)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Optional
|
||
|
||
from app.core.storage import get_storage_service
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
|
||
from packages.config import get_api_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed
|
||
MAX_ATTEMPTS = 3
|
||
|
||
|
||
class GpuLipsyncService:
|
||
"""GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage)."""
|
||
|
||
RESULT_PREFIX = "gpu-lipsync/results/"
|
||
INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量
|
||
|
||
# ── 公共入口 ────────────────────────────────────────────────────
|
||
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
self.settings = get_api_settings()
|
||
self.storage = get_storage_service()
|
||
|
||
# ── Worker 注册/心跳 ────────────────────────────────────────────
|
||
|
||
def register_worker(
|
||
self,
|
||
worker_id: str,
|
||
hostname: str = "",
|
||
gpu_name: str = "",
|
||
free_vram_mb: int = 0,
|
||
capabilities: str = "musetalk",
|
||
) -> GpuWorkerModel:
|
||
now = datetime.now(UTC)
|
||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||
if worker is None:
|
||
worker = GpuWorkerModel(
|
||
worker_id=worker_id,
|
||
hostname=hostname,
|
||
gpu_name=gpu_name,
|
||
free_vram_mb=free_vram_mb,
|
||
capabilities=capabilities,
|
||
last_heartbeat_at=now,
|
||
created_at=now,
|
||
)
|
||
self.db.add(worker)
|
||
else:
|
||
worker.hostname = hostname or worker.hostname
|
||
worker.gpu_name = gpu_name or worker.gpu_name
|
||
worker.free_vram_mb = free_vram_mb
|
||
worker.capabilities = capabilities or worker.capabilities
|
||
worker.last_heartbeat_at = now
|
||
self.db.commit()
|
||
return worker
|
||
|
||
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
|
||
|
||
def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
|
||
|
||
同时会:
|
||
- 把 processing 状态且超时(超过 gpu_task_timeout_seconds 无心跳)的任务
|
||
回退为 pending(attempt++,超过 MAX_ATTEMPTS 置 failed),让其它 worker 认领。
|
||
- 刷新 worker 心跳。
|
||
"""
|
||
now = datetime.now(UTC)
|
||
self._recover_timed_out_tasks(now)
|
||
# 更新 worker 心跳
|
||
self._touch_worker(worker_id, now)
|
||
|
||
# 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态)
|
||
task = (
|
||
self.db.query(GpuLipsyncTaskModel)
|
||
.filter(GpuLipsyncTaskModel.status == "pending")
|
||
.order_by(GpuLipsyncTaskModel.created_at.asc())
|
||
.first()
|
||
)
|
||
if task is None:
|
||
self.db.commit()
|
||
return None
|
||
|
||
# 原子 claim:用 UPDATE WHERE status=pending 避免并发
|
||
upd_rows = (
|
||
self.db.query(GpuLipsyncTaskModel)
|
||
.filter(
|
||
GpuLipsyncTaskModel.id == task.id,
|
||
GpuLipsyncTaskModel.status == "pending",
|
||
)
|
||
.update(
|
||
{
|
||
GpuLipsyncTaskModel.status: "processing",
|
||
GpuLipsyncTaskModel.worker_id: worker_id,
|
||
GpuLipsyncTaskModel.started_at: now,
|
||
GpuLipsyncTaskModel.last_heartbeat_at: now,
|
||
GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1,
|
||
GpuLipsyncTaskModel.updated_at: now,
|
||
},
|
||
synchronize_session=False,
|
||
)
|
||
)
|
||
self.db.commit()
|
||
if upd_rows == 0:
|
||
# 被其它 worker 抢先了
|
||
return None
|
||
self.db.refresh(task)
|
||
# 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期)
|
||
expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD
|
||
task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires)
|
||
task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires)
|
||
task._signed_upload_url = self.storage.get_upload_url(
|
||
self._result_key(task.id),
|
||
expires_seconds=expires,
|
||
content_type="video/mp4",
|
||
)
|
||
task._upload_expires_at = now + timedelta(seconds=expires)
|
||
return task
|
||
|
||
# ── 上报结果 ──────────────────────────────────────────────────
|
||
|
||
def report_result(
|
||
self,
|
||
task_id: str,
|
||
worker_id: str,
|
||
success: bool,
|
||
duration_seconds: float = 0.0,
|
||
error_msg: str = "",
|
||
) -> GpuLipsyncTaskModel:
|
||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||
if task is None:
|
||
raise KeyError(f"task {task_id} not found")
|
||
now = datetime.now(UTC)
|
||
if success:
|
||
task.status = "done"
|
||
task.result_url = self._result_key(task_id)
|
||
task.result_duration = duration_seconds or 0.0
|
||
task.error_msg = ""
|
||
task.finished_at = now
|
||
else:
|
||
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
|
||
if task.attempt < MAX_ATTEMPTS:
|
||
task.status = "pending"
|
||
task.worker_id = ""
|
||
task.started_at = None
|
||
task.error_msg = error_msg[:2000]
|
||
logger.warning(
|
||
"GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d): %s",
|
||
task_id,
|
||
worker_id,
|
||
task.attempt,
|
||
error_msg[:200],
|
||
)
|
||
else:
|
||
task.status = "failed"
|
||
task.error_msg = error_msg[:2000]
|
||
task.finished_at = now
|
||
logger.error(
|
||
"GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s",
|
||
task_id,
|
||
MAX_ATTEMPTS,
|
||
error_msg[:200],
|
||
)
|
||
task.updated_at = now
|
||
task.last_heartbeat_at = now
|
||
self._touch_worker(worker_id, now)
|
||
self.db.commit()
|
||
self.db.refresh(task)
|
||
return task
|
||
|
||
# ── 业务侧查询 ────────────────────────────────────────────────
|
||
|
||
def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||
return self.db.get(GpuLipsyncTaskModel, task_id)
|
||
|
||
def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||
return (
|
||
self.db.query(GpuLipsyncTaskModel)
|
||
.filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id)
|
||
.order_by(GpuLipsyncTaskModel.created_at.desc())
|
||
.first()
|
||
)
|
||
|
||
# ── 创建任务(业务侧调用) ────────────────────────────────────
|
||
|
||
def create_task(
|
||
self,
|
||
video_url: str,
|
||
audio_url: str,
|
||
lipsync_job_id: str = "",
|
||
user_id: str = "",
|
||
project_id: str = "",
|
||
) -> GpuLipsyncTaskModel:
|
||
task_id = str(uuid.uuid4())
|
||
now = datetime.now(UTC)
|
||
task = GpuLipsyncTaskModel(
|
||
id=task_id,
|
||
lipsync_job_id=lipsync_job_id,
|
||
user_id=user_id,
|
||
project_id=project_id,
|
||
video_url=video_url,
|
||
audio_url=audio_url,
|
||
status="pending",
|
||
attempt=0,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.db.add(task)
|
||
self.db.commit()
|
||
self.db.refresh(task)
|
||
logger.info(
|
||
"创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)",
|
||
task_id,
|
||
lipsync_job_id,
|
||
user_id,
|
||
)
|
||
return task
|
||
|
||
# ── 内部辅助 ──────────────────────────────────────────────────
|
||
|
||
def _result_key(self, task_id: str) -> str:
|
||
return f"{self.RESULT_PREFIX}{task_id}.mp4"
|
||
|
||
def _touch_worker(self, worker_id: str, now: datetime) -> None:
|
||
if not worker_id:
|
||
return
|
||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||
if worker is not None:
|
||
worker.last_heartbeat_at = now
|
||
self.db.flush()
|
||
else:
|
||
# 自注册(poll 时允许自动建一个空 worker 记录,运维可见)
|
||
worker = GpuWorkerModel(
|
||
worker_id=worker_id,
|
||
hostname="",
|
||
gpu_name="",
|
||
free_vram_mb=0,
|
||
capabilities="musetalk",
|
||
last_heartbeat_at=now,
|
||
created_at=now,
|
||
)
|
||
self.db.add(worker)
|
||
self.db.flush()
|
||
|
||
def _recover_timed_out_tasks(self, now: datetime) -> None:
|
||
"""扫描 processing 状态且超时(无心跳)的任务,回退 pending 或失败."""
|
||
timeout = self.settings.gpu_task_timeout_seconds
|
||
cutoff = now - timedelta(seconds=timeout)
|
||
stuck_tasks = (
|
||
self.db.query(GpuLipsyncTaskModel)
|
||
.filter(
|
||
GpuLipsyncTaskModel.status == "processing",
|
||
GpuLipsyncTaskModel.last_heartbeat_at < cutoff,
|
||
)
|
||
.all()
|
||
)
|
||
for t in stuck_tasks:
|
||
if t.attempt >= MAX_ATTEMPTS:
|
||
t.status = "failed"
|
||
t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽"
|
||
t.finished_at = now
|
||
else:
|
||
t.status = "pending"
|
||
t.worker_id = ""
|
||
t.started_at = None
|
||
t.error_msg = f"worker 心跳超时({timeout}s),等待重试"
|
||
logger.warning("GPU 任务 %s 心跳超时,回退 pending(attempt=%d)", t.id, t.attempt)
|
||
t.updated_at = now
|
||
if stuck_tasks:
|
||
self.db.flush()
|