Files
xiaoxia-saas/tests/unit/test_gpu_lipsync_service.py
T
xiaoxia 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
feat(gpu): #1978 MuseTalk GPU Worker 反向轮询对接 - 后端API+Worker脚本
新增 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查询,全绿。
2026-09-18 19:37:37 +08:00

192 lines
7.3 KiB
Python

"""GpuLipsyncService 单元测试 — 覆盖任务创建、轮询认领、结果上报、超时回退等核心逻辑.
使用 SQLite 内存数据库,mock 掉存储层(不真实调用 OSS)。
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest import mock
import pytest
# 确保 packages / apps/api 可导入
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
for p in (ROOT, os.path.join(ROOT, "apps", "api"), os.path.join(ROOT, "packages")):
if p not in sys.path:
sys.path.insert(0, p)
# 强制使用内存 SQLite(避免依赖 PG)
os.environ["APP_ENV"] = "development"
os.environ["JWT_SECRET_KEY"] = "dev-secret-key-for-testing-00000000"
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
os.environ["USE_IN_MEMORY_DB"] = "1"
os.environ["GPU_WORKER_TOKEN"] = "" # development 空 token 放行
def _build_session():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# 使用 packages 的 Base
from packages.adapters.sqlalchemy_impl import models as _ # noqa: F401 # 触发 ORM 注册
from packages.adapters.sqlalchemy_impl.models import Base
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
return Session()
@pytest.fixture
def svc():
from app.services.gpu_lipsync_service import GpuLipsyncService
db = _build_session()
service = GpuLipsyncService(db)
# mock 存储签名(SQLite 测试无 OSS)
service.storage = mock.MagicMock()
service.storage.get_download_url.side_effect = (
lambda k, expires_seconds=3600: f"https://signed.example.com/download/{k}?e={expires_seconds}"
)
service.storage.get_upload_url.side_effect = (
lambda k, expires_seconds=3600, content_type="video/mp4": f"https://signed.example.com/upload/{k}?e={expires_seconds}"
)
return service
# ── 创建任务 ──────────────────────────────────────────────────────
def test_create_task(svc):
task = svc.create_task(
video_url="uploads/v.mp4",
audio_url="uploads/a.mp3",
lipsync_job_id="lip-1",
user_id="u-1",
project_id="p-1",
)
assert task.id
assert task.status == "pending"
assert task.lipsync_job_id == "lip-1"
assert task.attempt == 0
assert task.video_url == "uploads/v.mp4"
# ── 轮询认领 ──────────────────────────────────────────────────────
def test_poll_returns_none_when_empty(svc):
assert svc.poll_task("w-1") is None
def test_poll_claims_pending_task(svc):
svc.create_task(video_url="uploads/v.mp4", audio_url="uploads/a.mp3")
claimed = svc.poll_task("w-1")
assert claimed is not None
assert claimed.status == "processing"
assert claimed.worker_id == "w-1"
assert claimed.attempt == 1
# 带签名 URL
assert claimed._signed_video_url.startswith("https://signed.example.com/download/")
assert claimed._signed_upload_url.startswith("https://signed.example.com/upload/")
# 再 poll 无任务
assert svc.poll_task("w-1") is None
def test_poll_concurrent_claim_only_one_wins(svc):
"""并发场景:两个 worker 同时 poll 只有一个能拿到任务(借助 update where status=pending)。"""
svc.create_task(video_url="v", audio_url="a")
t1 = svc.poll_task("w-1")
t2 = svc.poll_task("w-2")
assert t1 is not None
assert t2 is None
# ── 结果上报 ──────────────────────────────────────────────────────
def test_report_result_success(svc):
t = svc.create_task(video_url="v", audio_url="a")
svc.poll_task("w-1") # claim
done = svc.report_result(t.id, "w-1", success=True, duration_seconds=12.5)
assert done.status == "done"
assert done.result_duration == 12.5
assert done.result_url.startswith("gpu-lipsync/results/")
assert done.finished_at is not None
def test_report_result_failure_requeues(svc):
t = svc.create_task(video_url="v", audio_url="a")
svc.poll_task("w-1")
failed = svc.report_result(t.id, "w-1", success=False, error_msg="MuseTalk crash")
assert failed.status == "pending" # 仍在重试次数内 → 回队
assert failed.worker_id == ""
assert failed.started_at is None
assert "MuseTalk crash" in failed.error_msg
def test_report_failure_exhausted_goes_failed(svc):
"""失败达到 MAX_ATTEMPTS 后标记 failed,不再回队.
poll 成功会将 attempt 从 0 开始自增;
第 1/2 次失败回队,第 3 次失败(attempt==MAX_ATTEMPTS)置 failed。
"""
from app.services import gpu_lipsync_service as mod
t = svc.create_task(video_url="v", audio_url="a")
# 模拟失败到上限:poll + fail 重复 MAX_ATTEMPTS 次
for i in range(mod.MAX_ATTEMPTS):
claimed = svc.poll_task(f"w-{i}")
assert claimed is not None, f"第 {i} 次 poll 应能拿到任务"
svc.report_result(t.id, claimed.worker_id, success=False, error_msg=f"fail {i}")
svc.db.refresh(t)
if i == mod.MAX_ATTEMPTS - 1:
assert t.status == "failed"
else:
assert t.status == "pending"
# ── 心跳/超时回退 ─────────────────────────────────────────────────
def test_timed_out_task_is_redispatched(svc):
"""processing 超过 gpu_task_timeout_seconds 无心跳 → 回退 pending."""
t = svc.create_task(video_url="v", audio_url="a")
svc.poll_task("w-1")
svc.db.refresh(t)
assert t.status == "processing"
# 手动把 last_heartbeat_at 设到很久以前
t.last_heartbeat_at = datetime.now(UTC) - timedelta(seconds=svc.settings.gpu_task_timeout_seconds + 10)
svc.db.commit()
# 再次 poll 会触发 _recover_timed_out_tasks 把它回队
claimed = svc.poll_task("w-2")
assert claimed is not None
assert claimed.id == t.id
assert claimed.worker_id == "w-2"
assert claimed.attempt == 2 # 又认领了一次
# ── Worker 注册 ───────────────────────────────────────────────────
def test_register_worker_creates_then_updates(svc):
w = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=3500)
assert w.worker_id == "w-1"
assert w.gpu_name == "RTX2060"
w2 = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=2000)
assert w2.free_vram_mb == 2000 # 更新
assert w2.created_at == w.created_at # 没新建
# ── get_by_lipsync_job ─────────────────────────────────────────────
def test_get_by_lipsync_job_returns_latest(svc):
svc.create_task(video_url="v", audio_url="a", lipsync_job_id="lip-1")
svc.create_task(video_url="v", audio_url="a", lipsync_job_id="lip-1")
latest = svc.get_by_lipsync_job("lip-1")
assert latest is not None