refactor: use factory for supervised uvicorn modes

This commit is contained in:
jxxghp
2026-08-21 19:51:33 +08:00
parent 0959831bae
commit bb57b22975
8 changed files with 323 additions and 137 deletions
+53 -6
View File
@@ -30,6 +30,7 @@ import setproctitle
import signal
import threading
from pathlib import Path
from typing import Optional
import uvicorn as uvicorn
from PIL import Image
@@ -55,7 +56,10 @@ elif SystemUtils.is_frozen():
from app.factory import app
from app.runtime.config import global_vars, settings
from app.runtime.topology import validate_process_topology
from app.runtime.topology import (
UnsupportedProcessTopologyError,
validate_process_topology,
)
from app.startup.database_initializer import prepare_database
setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -69,15 +73,58 @@ class MoviePilotServer(uvicorn.Server):
super().handle_exit(sig, frame)
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=settings.API_WORKERS,
timeout_graceful_shutdown=60))
APP_FACTORY = "app.factory:create_app"
Server: Optional[MoviePilotServer] = None
def create_server() -> MoviePilotServer:
"""创建不带 reload/multiprocess supervisor 的单进程生产服务器。"""
server = MoviePilotServer(
Config(
app,
host=settings.HOST,
port=settings.PORT,
reload=False,
workers=1,
timeout_graceful_shutdown=60,
)
)
# 数据库准备阶段收到的信号早于 Server 物化,创建后必须继承既有停止意图。
if global_vars.is_system_stopped:
server.should_exit = True
return server
def run_api_server() -> None:
"""按开发 reload、安全模式多进程或生产单进程选择 Uvicorn 入口。"""
global Server
supervised = settings.DEV or settings.API_WORKERS > 1
if supervised:
if settings.DEV and settings.API_WORKERS > 1:
raise UnsupportedProcessTopologyError(
"Uvicorn reload 与多 worker 不能同时启用;"
"开发模式请设置 API_WORKERS=1。"
)
Server = None
uvicorn.run(
APP_FACTORY,
factory=True,
host=settings.HOST,
port=settings.PORT,
reload=settings.DEV,
workers=settings.API_WORKERS,
timeout_graceful_shutdown=60,
)
return
Server = create_server()
Server.run()
def request_shutdown() -> None:
"""发布协作停止标志并请求 Uvicorn 退出"""
global_vars.stop_system()
Server.should_exit = True
if Server is not None:
Server.should_exit = True
def start_tray():
@@ -143,7 +190,7 @@ def run_application() -> None:
start_tray()
prepare_database()
Server.run()
run_api_server()
if __name__ == '__main__':
+3
View File
@@ -235,6 +235,9 @@ sequenceDiagram
- **缓存装配先于业务导入**:缓存装饰器会在业务模块 import 时创建后端,
因此 `configure_cache_dependencies()``lifecycle.py` 顶部即执行。
- **Uvicorn 入口分流**:生产单 worker 使用带协作停止语义的 `MoviePilotServer`;开发 reload
和安全模式多 worker 使用 `app.factory:create_app` import string/factory,由 supervisor
创建应用实例。`app.factory:app` 继续保留给既有 ASGI supervisor 和测试使用。
- **引擎预热 fail-fast**:同步/异步数据库引擎在单线程期完成首次创建,
避免调度器放出大量线程后再创建引擎导致连接锁竞争。
- **安全模式**`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。
+7 -3
View File
@@ -61,7 +61,10 @@ uv sync --locked --no-dev --no-install-project
./scripts/start-local.sh logs --follow
```
默认会使用 `DEBUG=true``DEV=true`,与 IDE 开发启动保持一致;如果不需要热重载,可以这样启动以降低资源占用:
默认会使用 `DEBUG=true``DEV=true`,与 IDE 开发启动保持一致。开发热重载通过
`app.factory:create_app` 的 import string/factory 入口运行,文件变化后由 Uvicorn 重新创建
应用结构;不会尝试在 reload 进程间传递已经实例化的 FastAPI 对象。如果不需要热重载,
可以这样启动以降低资源占用:
```bash
DEV=false ./scripts/start-local.sh
@@ -192,8 +195,9 @@ Safety 直接识别项目清单和锁文件,不需要生成或维护 requireme
uv run --locked --no-sync pylint app/
```
GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构与 Pylint 门禁;最新官方插件仓
通过每周或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。
GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python
文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周
或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。
### 7. 参考资源
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0ARCH-201203)与 ARCH-210 已完成,后续任务按 ID 独立提交和回滚
> 实施进度:阶段 0ARCH-201203)与 ARCH-210211 已完成,后续任务按 ID 独立提交和回滚
## 1. 结论先行
+130 -124
View File
@@ -1,41 +1,41 @@
{
"schema_version": 1,
"generated_at": "2026-08-17T15:05:56.902649+00:00",
"generated_at": "2026-08-21T11:45:07.622632+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit",
"python": "3.12.6",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_module_count": 1779,
"max_ms": 968.467,
"median_ms": 968.335,
"min_ms": 960.971,
"loaded_module_count": 1809,
"max_ms": 1023.29,
"median_ms": 1018.356,
"min_ms": 992.011,
"samples_ms": [
968.335,
968.467,
960.971
1023.29,
1018.356,
992.011
]
},
"app.factory": {
"loaded_module_count": 1791,
"max_ms": 1026.357,
"median_ms": 1019.56,
"min_ms": 997.442,
"loaded_module_count": 1818,
"max_ms": 1058.352,
"median_ms": 1048.27,
"min_ms": 1045.441,
"samples_ms": [
1019.56,
1026.357,
997.442
1058.352,
1048.27,
1045.441
]
},
"app.main": {
"loaded_module_count": 1933,
"max_ms": 1091.202,
"median_ms": 1083.385,
"min_ms": 1076.762,
"loaded_module_count": 1961,
"max_ms": 1316.051,
"median_ms": 1144.056,
"min_ms": 1138.523,
"samples_ms": [
1091.202,
1076.762,
1083.385
1138.523,
1316.051,
1144.056
]
}
},
@@ -47,78 +47,81 @@
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.55,
"full_lifespan_ms": 0.621,
"startup_ms": 0.566,
"full_lifespan_ms": 0.67,
"stage_ms": {
"HTTP 基础能力": 0.071,
"领域依赖装配": 0.035,
"HTTP 基础能力": 0.077,
"领域依赖装配": 0.038,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.025,
"路由": 0.022,
"模块服务": 0.022,
"插件备份恢复": 0.022,
"插件": 0.021,
"定时器": 0.022,
"监控器": 0.022,
"待处理整理回放": 0.023,
"命令服务": 0.022,
"工作流": 0.022,
"插件同步与启动收尾": 0.035
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.574,
"full_lifespan_ms": 0.669,
"stage_ms": {
"HTTP 基础能力": 0.08,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.029,
"数据库连接预算": 0.026,
"路由": 0.023,
"模块服务": 0.021,
"插件备份恢复": 0.021,
"数据库连接预算": 0.027,
"路由": 0.027,
"模块服务": 0.023,
"插件备份恢复": 0.022,
"插件": 0.02,
"定时器": 0.02,
"监控器": 0.023,
"待处理整理回放": 0.025,
"命令服务": 0.023,
"工作流": 0.023,
"插件同步与启动收尾": 0.03
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.571,
"full_lifespan_ms": 0.678,
"stage_ms": {
"HTTP 基础能力": 0.077,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.029,
"路由": 0.025,
"模块服务": 0.022,
"插件备份恢复": 0.022,
"插件": 0.023,
"定时器": 0.023,
"监控器": 0.022,
"待处理整理回放": 0.024,
"待处理整理回放": 0.021,
"命令服务": 0.023,
"工作流": 0.022
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.548,
"full_lifespan_ms": 0.613,
"stage_ms": {
"HTTP 基础能力": 0.075,
"领域依赖装配": 0.035,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.028,
"路由": 0.026,
"模块服务": 0.022,
"插件备份恢复": 0.023,
"插件": 0.023,
"定时器": 0.021,
"监控器": 0.019,
"待处理整理回放": 0.024,
"命令服务": 0.023,
"工作流": 0.022
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.549,
"full_lifespan_ms": 0.616,
"stage_ms": {
"HTTP 基础能力": 0.07,
"领域依赖装配": 0.034,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.028,
"路由": 0.026,
"模块服务": 0.023,
"插件备份恢复": 0.024,
"插件": 0.023,
"定时器": 0.021,
"监控器": 0.019,
"待处理整理回放": 0.024,
"命令服务": 0.023,
"工作流": 0.022
"工作流": 0.022,
"插件同步与启动收尾": 0.036
},
"threads_before": 2,
"threads_started": 2,
@@ -129,8 +132,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.549,
"median_full_lifespan_ms": 0.616,
"median_startup_ms": 0.571,
"median_full_lifespan_ms": 0.67,
"enabled_component_count": 14
},
"safe": {
@@ -138,15 +141,38 @@
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.399,
"full_lifespan_ms": 0.46,
"startup_ms": 0.419,
"full_lifespan_ms": 0.523,
"stage_ms": {
"HTTP 基础能力": 0.069,
"领域依赖装配": 0.035,
"数据库引擎预热": 0.027,
"HTTP 基础能力": 0.082,
"领域依赖装配": 0.04,
"数据库引擎预热": 0.031,
"数据库连接预算": 0.029,
"路由": 0.025,
"模块服务": 0.022,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.488,
"full_lifespan_ms": 0.582,
"stage_ms": {
"HTTP 基础能力": 0.078,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.03,
"数据库连接预算": 0.027,
"路由": 0.024,
"模块服务": 0.026
"模块服务": 0.022,
"插件同步与启动收尾": 0.032
},
"threads_before": 2,
"threads_started": 2,
@@ -159,36 +185,16 @@
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.382,
"full_lifespan_ms": 0.443,
"startup_ms": 0.41,
"full_lifespan_ms": 0.538,
"stage_ms": {
"HTTP 基础能力": 0.065,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.025,
"路由": 0.022,
"模块服务": 0.023
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.416,
"full_lifespan_ms": 0.482,
"stage_ms": {
"HTTP 基础能力": 0.074,
"HTTP 基础能力": 0.076,
"领域依赖装配": 0.034,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.027,
"数据库连接预算": 0.026,
"路由": 0.025,
"模块服务": 0.025
"模块服务": 0.024,
"插件同步与启动收尾": 0.06
},
"threads_before": 2,
"threads_started": 2,
@@ -199,8 +205,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.399,
"median_full_lifespan_ms": 0.46,
"median_startup_ms": 0.419,
"median_full_lifespan_ms": 0.538,
"enabled_component_count": 6
}
}
+2 -2
View File
@@ -435,7 +435,7 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
"prepare_database",
lambda: calls.append("prepare_database"),
)
monkeypatch.setattr(main.Server, "run", lambda: calls.append("server"))
monkeypatch.setattr(main, "run_api_server", lambda: calls.append("server"))
main.run_application()
@@ -469,7 +469,7 @@ def test_application_does_not_start_server_after_migration_failure(monkeypatch):
"prepare_database",
MagicMock(side_effect=migration_error),
)
monkeypatch.setattr(main.Server, "run", server_run)
monkeypatch.setattr(main, "run_api_server", server_run)
with pytest.raises(RuntimeError) as raised:
main.run_application()
+1 -1
View File
@@ -61,7 +61,7 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
monkeypatch.setattr(main.signal, "signal", signal_handler)
monkeypatch.setattr(main, "start_tray", start_tray)
monkeypatch.setattr(main, "prepare_database", prepare_database)
monkeypatch.setattr(main.Server, "run", server_run)
monkeypatch.setattr(main, "run_api_server", server_run)
with pytest.raises(UnsupportedProcessTopologyError):
main.run_application()
+126
View File
@@ -0,0 +1,126 @@
"""MoviePilot Uvicorn factory、reload 与生产服务器入口测试。"""
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError
PROJECT_ROOT = Path(__file__).parents[1]
def test_create_app_does_not_start_plugin_manager_or_threads(monkeypatch):
"""ASGI factory 只构建应用结构,不得在创建阶段物化插件运行时。"""
plugin_manager = MagicMock(side_effect=AssertionError("plugin runtime started"))
monkeypatch.setattr(factory, "PluginManager", plugin_manager)
threads_before = threading.active_count()
created = factory.create_app()
assert created is not factory.app
assert threading.active_count() == threads_before
plugin_manager.assert_not_called()
def test_production_entry_uses_custom_single_process_server(monkeypatch):
"""生产单 worker 保留发布协作停止标志的自定义 Server。"""
server = MagicMock()
monkeypatch.setattr(main.settings, "DEV", False)
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
monkeypatch.setattr(main, "create_server", MagicMock(return_value=server))
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
main.run_api_server()
assert main.Server is server
server.run.assert_called_once_with()
uvicorn_run.assert_not_called()
def test_development_reload_uses_import_string_factory(monkeypatch):
"""开发 reload 必须让 Uvicorn 重新导入 factory,而不是序列化 app 实例。"""
monkeypatch.setattr(main.settings, "DEV", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
monkeypatch.setattr(main, "Server", MagicMock())
main.run_api_server()
assert main.Server is None
uvicorn_run.assert_called_once_with(
main.APP_FACTORY,
factory=True,
host=main.settings.HOST,
port=main.settings.PORT,
reload=True,
workers=1,
timeout_graceful_shutdown=60,
)
def test_safe_mode_multi_worker_uses_import_string_factory(monkeypatch):
"""安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。"""
monkeypatch.setattr(main.settings, "DEV", False)
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 2)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
main.run_api_server()
assert uvicorn_run.call_args.kwargs["factory"] is True
assert uvicorn_run.call_args.kwargs["reload"] is False
assert uvicorn_run.call_args.kwargs["workers"] == 2
def test_reload_and_multiple_workers_are_rejected_together(monkeypatch):
"""Uvicorn 不支持的 reload + workers 组合必须给出明确错误。"""
monkeypatch.setattr(main.settings, "DEV", True)
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 2)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
with pytest.raises(UnsupportedProcessTopologyError, match="不能同时启用"):
main.run_api_server()
uvicorn_run.assert_not_called()
def test_request_shutdown_is_safe_before_server_creation(monkeypatch):
"""数据库准备或 reload supervisor 阶段收到退出请求时不依赖 Server 已创建。"""
stop_system = MagicMock()
monkeypatch.setattr(main.global_vars, "stop_system", stop_system)
monkeypatch.setattr(main, "Server", None)
main.request_shutdown()
stop_system.assert_called_once_with()
def test_production_server_preserves_shutdown_requested_before_creation(
monkeypatch,
):
"""数据库准备期间收到的停止请求必须传递给随后创建的生产 Server。"""
stop_event = threading.Event()
stop_event.set()
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
server = main.create_server()
assert server.should_exit is True
def test_local_launcher_keeps_module_entrypoint():
"""本地开发脚本继续通过 app.main 进入统一启动准备流程。"""
script = (PROJECT_ROOT / "scripts" / "start-local.sh").read_text(
encoding="utf-8"
)
assert 'exec "$VENV_PYTHON" -m app.main' in script