main:支持 Worker 模式运行并优化任务管理
变更内容: - 在 `Dockerfile` 和 `docker-compose.yml` 中添加 Worker 模式支持,包含运行模式 `RUN_MODE` 的配置。 - 更新 API 路由,改为将任务入队处理,并由 Worker 执行。 - 在 JobManager 中新增任务队列及分布式锁功能,支持任务的入队、出队、执行控制以及重试机制。 - 添加全局并发控制逻辑,避免任务超额运行。 - 扩展单元测试,覆盖任务队列、锁机制和并发控制的各类场景。 - 在 Serverless 配置中分别为 API 和 Worker 添加独立服务定义。 提升任务调度灵活性,增强系统可靠性与扩展性。
This commit is contained in:
@@ -217,7 +217,7 @@ class TestJobsAPI:
|
||||
"created_at": "2026-02-02T10:00:00+00:00",
|
||||
}
|
||||
)
|
||||
mock_manager.execute_job = AsyncMock()
|
||||
mock_manager.enqueue_job = AsyncMock(return_value=True)
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
response = client.post(
|
||||
@@ -486,14 +486,298 @@ class TestConcurrencyControl:
|
||||
|
||||
def test_concurrency_status_api(self, client):
|
||||
"""测试并发状态 API 端点"""
|
||||
response = client.get("/jobs/concurrency/status")
|
||||
with patch(
|
||||
"functional_scaffold.api.routes.get_job_manager", new_callable=AsyncMock
|
||||
) as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.is_available.return_value = True
|
||||
mock_manager.get_concurrency_status.return_value = {
|
||||
"max_concurrent": 10,
|
||||
"available_slots": 8,
|
||||
"running_jobs": 2,
|
||||
}
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
response = client.get("/jobs/concurrency/status")
|
||||
|
||||
assert "max_concurrent" in data
|
||||
assert "available_slots" in data
|
||||
assert "running_jobs" in data
|
||||
assert isinstance(data["max_concurrent"], int)
|
||||
assert isinstance(data["available_slots"], int)
|
||||
assert isinstance(data["running_jobs"], int)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert "max_concurrent" in data
|
||||
assert "available_slots" in data
|
||||
assert "running_jobs" in data
|
||||
assert isinstance(data["max_concurrent"], int)
|
||||
assert isinstance(data["available_slots"], int)
|
||||
assert isinstance(data["running_jobs"], int)
|
||||
|
||||
|
||||
class TestJobQueue:
|
||||
"""测试任务队列功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_job(self):
|
||||
"""测试任务入队"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.lpush = AsyncMock(return_value=1)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.enqueue_job("test-job-id")
|
||||
|
||||
assert result is True
|
||||
mock_redis.lpush.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_job_without_redis(self):
|
||||
"""测试 Redis 不可用时入队"""
|
||||
manager = JobManager()
|
||||
|
||||
result = await manager.enqueue_job("test-job-id")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dequeue_job(self):
|
||||
"""测试任务出队"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.brpop = AsyncMock(return_value=("job:queue", "test-job-id"))
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.dequeue_job(timeout=5)
|
||||
|
||||
assert result == "test-job-id"
|
||||
mock_redis.brpop.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dequeue_job_timeout(self):
|
||||
"""测试任务出队超时"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.brpop = AsyncMock(return_value=None)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.dequeue_job(timeout=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dequeue_job_without_redis(self):
|
||||
"""测试 Redis 不可用时出队"""
|
||||
manager = JobManager()
|
||||
|
||||
result = await manager.dequeue_job(timeout=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDistributedLock:
|
||||
"""测试分布式锁功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_job_lock(self):
|
||||
"""测试获取任务锁"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.set = AsyncMock(return_value=True)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.acquire_job_lock("test-job-id")
|
||||
|
||||
assert result is True
|
||||
mock_redis.set.assert_called_once()
|
||||
call_args = mock_redis.set.call_args
|
||||
assert call_args[0][0] == "job:lock:test-job-id"
|
||||
assert call_args[1]["nx"] is True
|
||||
assert "ex" in call_args[1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_job_lock_already_locked(self):
|
||||
"""测试获取已被锁定的任务锁"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.set = AsyncMock(return_value=None) # 锁已存在
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.acquire_job_lock("test-job-id")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_job_lock(self):
|
||||
"""测试释放任务锁"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.delete = AsyncMock(return_value=1)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.release_job_lock("test-job-id")
|
||||
|
||||
assert result is True
|
||||
mock_redis.delete.assert_called_once_with("job:lock:test-job-id")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_job_lock_without_redis(self):
|
||||
"""测试 Redis 不可用时释放锁"""
|
||||
manager = JobManager()
|
||||
|
||||
result = await manager.release_job_lock("test-job-id")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGlobalConcurrency:
|
||||
"""测试全局并发控制功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_concurrency(self):
|
||||
"""测试增加并发计数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.incr = AsyncMock(return_value=5)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.increment_concurrency()
|
||||
|
||||
assert result == 5
|
||||
mock_redis.incr.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decrement_concurrency(self):
|
||||
"""测试减少并发计数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.decr = AsyncMock(return_value=4)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.decrement_concurrency()
|
||||
|
||||
assert result == 4
|
||||
mock_redis.decr.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decrement_concurrency_prevent_negative(self):
|
||||
"""测试防止并发计数变为负数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.decr = AsyncMock(return_value=-1)
|
||||
mock_redis.set = AsyncMock()
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.decrement_concurrency()
|
||||
|
||||
assert result == 0
|
||||
mock_redis.set.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_global_concurrency(self):
|
||||
"""测试获取全局并发数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value="7")
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.get_global_concurrency()
|
||||
|
||||
assert result == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_global_concurrency_empty(self):
|
||||
"""测试获取空的全局并发数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.get_global_concurrency()
|
||||
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_execute(self):
|
||||
"""测试检查是否可执行"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value="5")
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
with patch("functional_scaffold.core.job_manager.settings") as mock_settings:
|
||||
mock_settings.max_concurrent_jobs = 10
|
||||
|
||||
result = await manager.can_execute()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_execute_at_limit(self):
|
||||
"""测试达到并发限制时"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value="10")
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
with patch("functional_scaffold.core.job_manager.settings") as mock_settings:
|
||||
mock_settings.max_concurrent_jobs = 10
|
||||
|
||||
result = await manager.can_execute()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestJobRetry:
|
||||
"""测试任务重试功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job_retry_count(self):
|
||||
"""测试获取任务重试次数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.hget = AsyncMock(return_value="2")
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.get_job_retry_count("test-job-id")
|
||||
|
||||
assert result == 2
|
||||
mock_redis.hget.assert_called_once_with("job:test-job-id", "retry_count")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job_retry_count_empty(self):
|
||||
"""测试获取空的重试次数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.hget = AsyncMock(return_value=None)
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.get_job_retry_count("test-job-id")
|
||||
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_job_retry(self):
|
||||
"""测试增加任务重试次数"""
|
||||
manager = JobManager()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.hincrby = AsyncMock()
|
||||
mock_redis.hget = AsyncMock(return_value="3")
|
||||
manager._redis_client = mock_redis
|
||||
|
||||
result = await manager.increment_job_retry("test-job-id")
|
||||
|
||||
assert result == 3
|
||||
mock_redis.hincrby.assert_called_once_with("job:test-job-id", "retry_count", 1)
|
||||
|
||||
Reference in New Issue
Block a user