5ab30f7706
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m26s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m42s
AI Code Review / AI Code Review (pull_request) Successful in 7m59s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 16m43s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m23s
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 / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m4s
新增 MuseTalk 本地 GPU Worker 反向轮询能力,解决阿里云CPU服务器无法跑GPU推理的问题。
后端API层(新增):
- 表 gpu_lipsync_tasks(id/status/video_url/audio_url/result_url/worker_id/
attempt/error_msg/created_at/started_at/finished_at/last_heartbeat_at)
- 表 gpu_workers(worker_id/hostname/gpu_name/free_vram_mb/capabilities/last_heartbeat_at)
- alembic 迁移 081_add_gpu_lipsync_tasks
- 4个接口(/api/v1/gpu 前缀,长期 GPU_WORKER_TOKEN Bearer 鉴权):
* POST /gpu/register — Worker 注册/心跳
* GET /gpu/lipsync/poll?worker_id=xxx — 拉取 pending 任务(带预签名下载+PUT上传URL),
原子 UPDATE WHERE status=pending 防并发;无任务返回204
* POST /gpu/lipsync/result — multipart 上报结果(支持Worker代传文件到OSS,
或先自PUT到预签名URL再无文件上报)
* GET /gpu/lipsync/status/{task_id} — 任务状态查询
- 超时回退:processing 任务超过 gpu_task_timeout_seconds(默认300s)无心跳
自动回退 pending 重试,最多 MAX_ATTEMPTS(3) 次
- 配置项:GPU_WORKER_TOKEN / GPU_TASK_TIMEOUT_SECONDS(SharedSettings)
- OSS 存储:SharedStorageService 新增 get_upload_url 预签名 PUT URL
- CI/环境:.env/.env.staging/.env.production + render_env.sh + ci-pipeline.yml
均注入 GPU_WORKER_TOKEN
Worker脚本(deploy/gpu_worker/,零项目代码依赖,仅依赖requests):
- gpu_worker.py:启动register→5s轮询→下载视频/音频→POST本地MuseTalk /inference
→multipart回传结果→失败本地重试+上报failed→单任务串行
- requirements.txt:仅 requests>=2.31
- xiaoxia-gpu-worker.service:systemd 开机自启单元
- .env.example:环境变量样例
- README.md:Linux/Windows部署+联调+故障排查
单元测试:10个用例覆盖创建/轮询/并发认领/成功/失败重试/
最大重试失败/超时回退/注册心跳/按lipsync_job查询,全绿。
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()
|