df99305dd6
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Build Staging API Image (pull_request) Has been skipped
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 / 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 API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m19s
AI Code Review / AI Code Review (pull_request) Failing after 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m11s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m23s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
问题:素材转码与视频生成共用 celery 默认队列、worker 单进程消费, 20+ 转码积压会把用户生成任务堵 40 分钟以上;孤儿清理把任务标 failed 后 Redis 队列消息未作废,消息被重投导致 failed→running 非法转换, worker 打印 ERROR 后继续产出半成品。 队列隔离: - 新增 packages/shared/celery_queues.py:generation/transcode/celery 三队列与 task_routes(generate_video→generation;ingest_asset/ classify_asset/duplication→transcode),apply_queue_settings() - worker 入口改双进程:generation worker 独占队列并内嵌 beat (prefetch=1, GENERATION_CONCURRENCY 默认 2),transcode worker 消费 transcode,celery(并发=总-2,最小 1),任一退出则整体终止 - compose/部署脚本/ps1 同步新增 GENERATION_CONCURRENCY 与健康检查 消息作废: - 新增 packages/shared/celery_orphan_guard.py:终态守卫 ensure_task_claimable、Redis 队列消息物理清理(JSON 信封解析, 按业务 id + celery headers.id 双匹配,未命中 rpush 保序)、 revoke_and_purge(control.revoke + 物理清队列双保险) - 入队点(生成/上传/分片/重试)send_task 后持久化 celery_task_id 到 generation_tasks/ingest_jobs(新列,067 迁移,失败仅 warning) - generate_video/ingest_asset 执行前校验 DB 状态:终态直接 discarded 不进业务逻辑;mark_processing 返回 False(非法转换)安全中止 - 孤儿/超时清理标 failed 时同时 revoke + 清队列消息 - pending 超时阈值 15→45 分钟,与 running 孤儿(20min)区分 测试:新增 22 个单测(路由表/真实 Redis 消息清理/终态守卫/ 非法转换中止/标 failed 后消息不重投/入队持久化),全量 14301 passed;067 迁移隔离 DDL 验证 upgrade/downgrade 通过。
356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""任务队列限流防护单元测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||
|
||
from app.core.task_enqueue import (
|
||
GLOBAL_PENDING_LIMIT,
|
||
USER_PENDING_LIMIT,
|
||
GlobalQueueFull,
|
||
UserPendingLimitExceeded,
|
||
check_queue_limits,
|
||
safe_enqueue_generation_task,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Mock helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class MockRepository:
|
||
"""支持 pending 计数的 mock repository。
|
||
|
||
支持通过 set_pending 动态修改计数,用于模拟入队后计数变化的并发场景。
|
||
"""
|
||
|
||
def __init__(self, user_pending: int = 0, global_pending: int = 0):
|
||
self._user_pending = user_pending
|
||
self._global_pending = global_pending
|
||
self._send_task_called = False
|
||
self.updated_tasks = []
|
||
|
||
def count_pending_by_user(self, user_id: str) -> int:
|
||
return self._user_pending
|
||
|
||
def count_pending_total(self) -> int:
|
||
return self._global_pending
|
||
|
||
def update(self, task):
|
||
self.updated_tasks.append(task)
|
||
return task
|
||
|
||
def set_pending(self, *, user_pending: int | None = None, global_pending: int | None = None):
|
||
"""动态修改 pending 计数,模拟并发场景。"""
|
||
if user_pending is not None:
|
||
self._user_pending = user_pending
|
||
if global_pending is not None:
|
||
self._global_pending = global_pending
|
||
|
||
|
||
class MockTask:
|
||
def __init__(self, task_id: str = "task-1", status: str = "pending"):
|
||
self.id = task_id
|
||
self.status = status
|
||
self.error_message = ""
|
||
|
||
def mark_failed(self, reason: str):
|
||
self.status = "failed"
|
||
self.error_message = reason
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def mock_celery(monkeypatch):
|
||
"""mock 掉 celery_app.send_task,避免真实发送。"""
|
||
mock_send = MagicMock()
|
||
monkeypatch.setattr("app.core.celery_app.celery_app.send_task", mock_send)
|
||
return mock_send
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 常量导出测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_limit_constants_are_exported():
|
||
"""限流阈值常量已导出,供业务代码引用。"""
|
||
assert USER_PENDING_LIMIT == 3
|
||
assert GLOBAL_PENDING_LIMIT == 20
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# check_queue_limits 单元测试(预检查用,>= 边界)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCheckQueueLimits:
|
||
"""队列限流检查函数测试(预检查语义,>= 上限即拒绝)。"""
|
||
|
||
def test_normal_passes_through(self):
|
||
"""正常范围内的任务不受限制。"""
|
||
repo = MockRepository(user_pending=1, global_pending=5)
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_user_limit_exceeded_raises(self):
|
||
"""用户 pending 超过上限抛 UserPendingLimitExceeded。"""
|
||
repo = MockRepository(user_pending=4, global_pending=5)
|
||
with pytest.raises(UserPendingLimitExceeded) as exc_info:
|
||
check_queue_limits("user-1", repo)
|
||
assert exc_info.value.user_id == "user-1"
|
||
assert exc_info.value.pending_count == 4
|
||
assert exc_info.value.limit == 3
|
||
|
||
def test_user_at_limit_also_raises(self):
|
||
"""用户 pending 刚好等于上限也拒绝(>= 边界)。"""
|
||
repo = MockRepository(user_pending=3, global_pending=5)
|
||
with pytest.raises(UserPendingLimitExceeded):
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_user_below_limit_passes(self):
|
||
"""用户 pending 比上限少 1,通过。"""
|
||
repo = MockRepository(user_pending=2, global_pending=5)
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_global_limit_exceeded_raises(self):
|
||
"""全局 pending 超过上限抛 GlobalQueueFull。"""
|
||
repo = MockRepository(user_pending=1, global_pending=21)
|
||
with pytest.raises(GlobalQueueFull) as exc_info:
|
||
check_queue_limits("user-1", repo)
|
||
assert exc_info.value.pending_count == 21
|
||
assert exc_info.value.limit == 20
|
||
|
||
def test_global_at_limit_also_raises(self):
|
||
"""全局 pending 刚好等于上限也拒绝(>= 边界)。"""
|
||
repo = MockRepository(user_pending=1, global_pending=20)
|
||
with pytest.raises(GlobalQueueFull):
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_global_below_limit_passes(self):
|
||
"""全局 pending 比上限少 1,通过。"""
|
||
repo = MockRepository(user_pending=1, global_pending=19)
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_global_takes_priority_over_user(self):
|
||
"""全局和用户都超限时,优先抛全局异常。"""
|
||
repo = MockRepository(user_pending=5, global_pending=25)
|
||
with pytest.raises(GlobalQueueFull):
|
||
check_queue_limits("user-1", repo)
|
||
|
||
def test_empty_user_id_skips_user_check(self):
|
||
"""不传 user_id 时跳过用户级检查,只做全局检查。"""
|
||
repo = MockRepository(user_pending=10, global_pending=5)
|
||
# 用户超限但不传 user_id → 全局未超限,应该通过
|
||
check_queue_limits("", repo)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# safe_enqueue_generation_task 限流集成测试(入队前用 >,包含当前任务)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSafeEnqueueWithLimits:
|
||
"""安全入队函数的限流功能测试。"""
|
||
|
||
def test_normal_task_enqueues_successfully(self, mock_celery):
|
||
"""正常任务入队成功,返回 True。"""
|
||
repo = MockRepository(user_pending=0, global_pending=0)
|
||
task = MockTask("task-1")
|
||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
assert result is True
|
||
mock_celery.assert_called_once_with("worker.generate_video", args=["task-1"])
|
||
# 成功入队后持久化 celery 消息 ID(#1714:孤儿清理据此 revoke/清队列)
|
||
assert len(repo.updated_tasks) == 1
|
||
assert task.celery_task_id
|
||
|
||
def test_user_limit_rejected_with_failed_status(self, mock_celery):
|
||
"""用户超限:任务标记为 failed,抛 UserPendingLimitExceeded。"""
|
||
repo = MockRepository(user_pending=5, global_pending=5)
|
||
task = MockTask("task-1")
|
||
with pytest.raises(UserPendingLimitExceeded):
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
mock_celery.assert_not_called()
|
||
assert task.status == "failed"
|
||
assert "限流" in task.error_message
|
||
assert len(repo.updated_tasks) == 1
|
||
|
||
def test_user_at_limit_still_passes(self, mock_celery):
|
||
"""用户 pending 刚好等于上限:入队前检查用 >,包含当前任务,刚好到上限不算超。
|
||
|
||
与预检查的 >= 语义一致:预检查时 pending=3 拒绝(不能再加新的),
|
||
但 safe_enqueue 被调用时任务已是 pending(就是第3个),
|
||
pending=3 不满足 >3,所以通过。
|
||
"""
|
||
repo = MockRepository(user_pending=3, global_pending=5)
|
||
task = MockTask("task-1")
|
||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
assert result is True
|
||
mock_celery.assert_called_once()
|
||
|
||
def test_user_one_over_limit_rejected(self, mock_celery):
|
||
"""用户 pending = limit + 1:超限被拒。"""
|
||
repo = MockRepository(user_pending=4, global_pending=5)
|
||
task = MockTask("task-1")
|
||
with pytest.raises(UserPendingLimitExceeded):
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
mock_celery.assert_not_called()
|
||
|
||
def test_global_limit_rejected_with_failed_status(self, mock_celery):
|
||
"""全局超限:任务标记为 failed,抛 GlobalQueueFull。"""
|
||
repo = MockRepository(user_pending=1, global_pending=21)
|
||
task = MockTask("task-1")
|
||
with pytest.raises(GlobalQueueFull):
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
mock_celery.assert_not_called()
|
||
assert task.status == "failed"
|
||
assert len(repo.updated_tasks) == 1
|
||
|
||
def test_global_at_limit_still_passes(self, mock_celery):
|
||
"""全局 pending 刚好等于上限:入队前检查用 >,包含当前任务,刚好到上限不算超。"""
|
||
repo = MockRepository(user_pending=1, global_pending=20)
|
||
task = MockTask("task-1")
|
||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
assert result is True
|
||
mock_celery.assert_called_once()
|
||
|
||
def test_no_user_id_skips_user_limit(self, mock_celery):
|
||
"""不传 user_id 时跳过用户级限流,只做全局检查。"""
|
||
repo = MockRepository(user_pending=10, global_pending=5)
|
||
task = MockTask("task-1")
|
||
result = safe_enqueue_generation_task(task, repo, user_id="")
|
||
assert result is True
|
||
mock_celery.assert_called_once()
|
||
|
||
def test_no_user_id_still_checks_global(self, mock_celery):
|
||
"""不传 user_id 时全局超限仍然被拦。"""
|
||
repo = MockRepository(user_pending=10, global_pending=25)
|
||
task = MockTask("task-1")
|
||
with pytest.raises(GlobalQueueFull):
|
||
safe_enqueue_generation_task(task, repo, user_id="")
|
||
mock_celery.assert_not_called()
|
||
|
||
def test_default_limits_match_constants(self, mock_celery):
|
||
"""默认配置与导出常量一致。"""
|
||
# 刚好在默认限制内(limit - 1)
|
||
repo = MockRepository(user_pending=2, global_pending=19)
|
||
task = MockTask("task-1")
|
||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
assert result is True
|
||
|
||
def test_update_failure_does_not_crash(self, mock_celery):
|
||
"""repository.update 失败也不崩溃,异常继续向上抛。"""
|
||
|
||
class BadRepo(MockRepository):
|
||
def update(self, task):
|
||
raise RuntimeError("db down")
|
||
|
||
repo = BadRepo(user_pending=5, global_pending=5)
|
||
task = MockTask("task-1")
|
||
# 仍然抛 UserPendingLimitExceeded,不会被 update 失败掩盖
|
||
with pytest.raises(UserPendingLimitExceeded):
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
mock_celery.assert_not_called()
|
||
# 任务状态还是变了(内存里改了)
|
||
assert task.status == "failed"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 入队后最终校验(并发竞态兜底)测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPostEnqueueFinalCheck:
|
||
"""入队后最终校验:模拟并发场景,Celery发送后计数增加被兜住。"""
|
||
|
||
def test_post_enqueue_global_overflow_rollback(self, mock_celery):
|
||
"""并发场景:入队前检查通过,但发送Celery后全局计数超限 → 回滚为failed。
|
||
|
||
模拟两个请求同时通过入队前检查(都查到 global=19),
|
||
都创建了任务(DB里变成 21),先发送Celery的那个在最终校验时被兜住。
|
||
"""
|
||
repo = MockRepository(user_pending=1, global_pending=20) # 入队前:20 > 20?否
|
||
task = MockTask("task-1")
|
||
|
||
# 模拟发送Celery后,另一个并发请求也创建了任务,全局变成21
|
||
def side_effect(*args, **kwargs):
|
||
repo.set_pending(global_pending=21)
|
||
|
||
mock_celery.side_effect = side_effect
|
||
|
||
with pytest.raises(GlobalQueueFull) as exc_info:
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
|
||
# Celery 确实发出去了(兜底不撤销 Celery,只回滚 DB 状态)
|
||
mock_celery.assert_called_once()
|
||
# 任务被标记为 failed
|
||
assert task.status == "failed"
|
||
assert "入队后" in task.error_message
|
||
assert exc_info.value.pending_count == 21
|
||
assert len(repo.updated_tasks) == 1
|
||
|
||
def test_post_enqueue_user_overflow_rollback(self, mock_celery):
|
||
"""并发场景:入队前检查通过,但发送Celery后用户计数超限 → 回滚为failed。"""
|
||
repo = MockRepository(user_pending=3, global_pending=5) # 入队前:3 > 3?否
|
||
task = MockTask("task-1")
|
||
|
||
def side_effect(*args, **kwargs):
|
||
repo.set_pending(user_pending=4)
|
||
|
||
mock_celery.side_effect = side_effect
|
||
|
||
with pytest.raises(UserPendingLimitExceeded) as exc_info:
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
|
||
mock_celery.assert_called_once()
|
||
assert task.status == "failed"
|
||
assert "入队后" in task.error_message
|
||
assert exc_info.value.user_id == "user-1"
|
||
assert exc_info.value.pending_count == 4
|
||
|
||
def test_post_enqueue_global_priority_over_user(self, mock_celery):
|
||
"""入队后校验:全局和用户都超限时,优先抛全局异常。"""
|
||
repo = MockRepository(user_pending=3, global_pending=20)
|
||
task = MockTask("task-1")
|
||
|
||
def side_effect(*args, **kwargs):
|
||
repo.set_pending(user_pending=5, global_pending=22)
|
||
|
||
mock_celery.side_effect = side_effect
|
||
|
||
with pytest.raises(GlobalQueueFull):
|
||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
|
||
assert task.status == "failed"
|
||
|
||
def test_post_enqueue_no_change_still_passes(self, mock_celery):
|
||
"""入队后计数没变 → 正常通过,不回滚。"""
|
||
repo = MockRepository(user_pending=2, global_pending=10)
|
||
task = MockTask("task-1")
|
||
|
||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||
|
||
assert result is True
|
||
mock_celery.assert_called_once()
|
||
assert task.status == "pending" # 状态没变
|
||
# 入队成功后持久化 celery_task_id(#1714),业务状态不变
|
||
assert len(repo.updated_tasks) == 1
|
||
assert task.celery_task_id
|
||
|
||
def test_post_enqueue_no_user_id_skips_user_check(self, mock_celery):
|
||
"""不传 user_id 时,入队后校验也跳过用户级,只查全局。"""
|
||
repo = MockRepository(user_pending=10, global_pending=5)
|
||
task = MockTask("task-1")
|
||
|
||
def side_effect(*args, **kwargs):
|
||
repo.set_pending(user_pending=15, global_pending=5) # 用户超限但全局没超
|
||
|
||
mock_celery.side_effect = side_effect
|
||
|
||
result = safe_enqueue_generation_task(task, repo, user_id="")
|
||
assert result is True # 用户级不检查,全局没超限 → 通过
|