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查询,全绿。
192 lines
7.3 KiB
Python
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
|