Files
xiaoxia-saas/tests/unit/test_gpu_lipsync_service.py
T
xiaoxia 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
feat(gpu): #1978 MuseTalk GPU Worker 反向轮询对接(后端API + Worker脚本) (#1979)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 19:59:49 +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