mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix(database): harden worker shutdown and overload propagation
This commit is contained in:
@@ -2640,6 +2640,8 @@
|
||||
"app.application.plugin.folders -> app.runtime.log",
|
||||
"app.application.plugin.folders -> app.schemas",
|
||||
"app.application.plugin.folders -> app.schemas.types",
|
||||
"app.application.plugin.install -> app.application",
|
||||
"app.application.plugin.install -> app.application.database",
|
||||
"app.application.recognition -> app.application",
|
||||
"app.application.recognition -> app.application.configuration",
|
||||
"app.application.recognition -> app.schemas",
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -24,6 +25,7 @@ from app.factory import (
|
||||
)
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.runtime.localization import LocaleHelper
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.response import Response
|
||||
|
||||
@@ -217,6 +219,40 @@ async def test_database_worker_overload_is_retryable_service_unavailable(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
f"{settings.API_V1_STR}/openai/v1/chat/completions",
|
||||
f"{settings.API_V1_STR}/anthropic/v1/messages",
|
||||
f"{settings.API_V1_STR}/mcp",
|
||||
],
|
||||
)
|
||||
async def test_database_worker_overload_preserves_retry_after_for_native_protocols(
|
||||
path: str,
|
||||
):
|
||||
"""OpenAI、Anthropic 和 MCP 的原生 503 也必须保留重试提示。"""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 123),
|
||||
"root_path": "",
|
||||
}
|
||||
response = await database_worker_overloaded_handler(
|
||||
Request(scope),
|
||||
DatabaseWorkerOverloadedError("worker full"),
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
|
||||
|
||||
async def test_validation_error_uses_unified_model(api_app: FastAPI):
|
||||
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -138,3 +139,64 @@ async def test_shutdown_rejects_new_work_and_waits_for_running_work() -> None:
|
||||
assert worker.snapshot().closing is True
|
||||
assert worker.snapshot().queued == 0
|
||||
assert worker.snapshot().running == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_timeout_keeps_running_owner_until_transaction_finishes() -> None:
|
||||
"""关闭超时应返回给生命周期编排,并保留执行器等待事务收敛。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=1)
|
||||
await worker.start()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def operation() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
|
||||
running = asyncio.create_task(worker.run(operation))
|
||||
await asyncio.to_thread(started.wait)
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(worker.shutdown(), timeout=0.01)
|
||||
|
||||
assert worker.snapshot().closing is True
|
||||
assert worker._executor is not None
|
||||
|
||||
release.set()
|
||||
await running
|
||||
await asyncio.sleep(0)
|
||||
await worker.shutdown()
|
||||
assert worker._executor is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_depth_metrics_emit_deltas_and_return_to_zero() -> None:
|
||||
"""队列和运行量指标按增减量上报,不能把绝对值累加成漂移。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=1)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def operation() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
|
||||
with patch("app.db.worker.record_metric") as record_metric:
|
||||
await worker.start()
|
||||
running = asyncio.create_task(worker.run(operation))
|
||||
await asyncio.to_thread(started.wait)
|
||||
release.set()
|
||||
await running
|
||||
await worker.shutdown()
|
||||
|
||||
queue_values = [
|
||||
call.args[1]
|
||||
for call in record_metric.call_args_list
|
||||
if call.args[0] == "db.worker.queue.depth"
|
||||
]
|
||||
active_values = [
|
||||
call.args[1]
|
||||
for call in record_metric.call_args_list
|
||||
if call.args[0] == "db.worker.active"
|
||||
]
|
||||
assert queue_values == [1.0, -1.0]
|
||||
assert active_values == [1.0, -1.0]
|
||||
|
||||
@@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
|
||||
|
||||
@@ -181,6 +182,26 @@ async def test_persistence_failure_restores_package_without_touching_runtime():
|
||||
reloader.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_worker_overload_rolls_back_and_reaches_api_boundary():
|
||||
"""配置 worker 背压完成补偿后继续抛出,交由 API 映射为 503。"""
|
||||
checkpoint = object()
|
||||
rollback = AsyncMock()
|
||||
command = _command(
|
||||
checkpointer=AsyncMock(return_value=checkpoint),
|
||||
writer=AsyncMock(side_effect=DatabaseWorkerOverloadedError("worker full")),
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
with pytest.raises(DatabaseWorkerOverloadedError):
|
||||
await command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
rollback.assert_awaited_once_with(checkpoint)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_failure_restores_list_files_and_previous_runtime():
|
||||
"""重载失败时依次恢复已安装列表、包文件和旧运行态。"""
|
||||
|
||||
Reference in New Issue
Block a user