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 signal
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional
import uvicorn as uvicorn import uvicorn as uvicorn
from PIL import Image from PIL import Image
@@ -55,7 +56,10 @@ elif SystemUtils.is_frozen():
from app.factory import app from app.factory import app
from app.runtime.config import global_vars, settings 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 from app.startup.database_initializer import prepare_database
setproctitle.setproctitle(settings.PROJECT_NAME) setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -69,15 +73,58 @@ class MoviePilotServer(uvicorn.Server):
super().handle_exit(sig, frame) super().handle_exit(sig, frame)
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT, APP_FACTORY = "app.factory:create_app"
reload=settings.DEV, workers=settings.API_WORKERS, Server: Optional[MoviePilotServer] = None
timeout_graceful_shutdown=60))
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: def request_shutdown() -> None:
"""发布协作停止标志并请求 Uvicorn 退出""" """发布协作停止标志并请求 Uvicorn 退出"""
global_vars.stop_system() global_vars.stop_system()
Server.should_exit = True if Server is not None:
Server.should_exit = True
def start_tray(): def start_tray():
@@ -143,7 +190,7 @@ def run_application() -> None:
start_tray() start_tray()
prepare_database() prepare_database()
Server.run() run_api_server()
if __name__ == '__main__': if __name__ == '__main__':
+3
View File
@@ -235,6 +235,9 @@ sequenceDiagram
- **缓存装配先于业务导入**:缓存装饰器会在业务模块 import 时创建后端, - **缓存装配先于业务导入**:缓存装饰器会在业务模块 import 时创建后端,
因此 `configure_cache_dependencies()``lifecycle.py` 顶部即执行。 因此 `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**:同步/异步数据库引擎在单线程期完成首次创建, - **引擎预热 fail-fast**:同步/异步数据库引擎在单线程期完成首次创建,
避免调度器放出大量线程后再创建引擎导致连接锁竞争。 避免调度器放出大量线程后再创建引擎导致连接锁竞争。
- **安全模式**`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 - **安全模式**`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 ./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 ```bash
DEV=false ./scripts/start-local.sh DEV=false ./scripts/start-local.sh
@@ -192,8 +195,9 @@ Safety 直接识别项目清单和锁文件,不需要生成或维护 requireme
uv run --locked --no-sync pylint app/ uv run --locked --no-sync pylint app/
``` ```
GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构与 Pylint 门禁;最新官方插件仓 GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python
通过每周或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。 文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周
或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。
### 7. 参考资源 ### 7. 参考资源
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` > 相关文档:`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. 结论先行 ## 1. 结论先行
+130 -124
View File
@@ -1,41 +1,41 @@
{ {
"schema_version": 1, "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", "platform": "macOS-26.5.2-arm64-arm-64bit",
"python": "3.12.6", "python": "3.12.6",
"repeat": 3, "repeat": 3,
"targets": { "targets": {
"app.startup.lifecycle": { "app.startup.lifecycle": {
"loaded_module_count": 1779, "loaded_module_count": 1809,
"max_ms": 968.467, "max_ms": 1023.29,
"median_ms": 968.335, "median_ms": 1018.356,
"min_ms": 960.971, "min_ms": 992.011,
"samples_ms": [ "samples_ms": [
968.335, 1023.29,
968.467, 1018.356,
960.971 992.011
] ]
}, },
"app.factory": { "app.factory": {
"loaded_module_count": 1791, "loaded_module_count": 1818,
"max_ms": 1026.357, "max_ms": 1058.352,
"median_ms": 1019.56, "median_ms": 1048.27,
"min_ms": 997.442, "min_ms": 1045.441,
"samples_ms": [ "samples_ms": [
1019.56, 1058.352,
1026.357, 1048.27,
997.442 1045.441
] ]
}, },
"app.main": { "app.main": {
"loaded_module_count": 1933, "loaded_module_count": 1961,
"max_ms": 1091.202, "max_ms": 1316.051,
"median_ms": 1083.385, "median_ms": 1144.056,
"min_ms": 1076.762, "min_ms": 1138.523,
"samples_ms": [ "samples_ms": [
1091.202, 1138.523,
1076.762, 1316.051,
1083.385 1144.056
] ]
} }
}, },
@@ -47,78 +47,81 @@
{ {
"mode": "normal", "mode": "normal",
"enabled_component_count": 14, "enabled_component_count": 14,
"startup_ms": 0.55, "startup_ms": 0.566,
"full_lifespan_ms": 0.621, "full_lifespan_ms": 0.67,
"stage_ms": { "stage_ms": {
"HTTP 基础能力": 0.071, "HTTP 基础能力": 0.077,
"领域依赖装配": 0.035, "领域依赖装配": 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.029,
"数据库连接预算": 0.026, "数据库连接预算": 0.027,
"路由": 0.023, "路由": 0.027,
"模块服务": 0.021, "模块服务": 0.023,
"插件备份恢复": 0.021, "插件备份恢复": 0.022,
"插件": 0.02, "插件": 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.023,
"监控器": 0.022, "监控器": 0.022,
"待处理整理回放": 0.024, "待处理整理回放": 0.021,
"命令服务": 0.023, "命令服务": 0.023,
"工作流": 0.022 "工作流": 0.022,
}, "插件同步与启动收尾": 0.036
"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
}, },
"threads_before": 2, "threads_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -129,8 +132,8 @@
"database_connections_started": 0 "database_connections_started": 0
} }
], ],
"median_startup_ms": 0.549, "median_startup_ms": 0.571,
"median_full_lifespan_ms": 0.616, "median_full_lifespan_ms": 0.67,
"enabled_component_count": 14 "enabled_component_count": 14
}, },
"safe": { "safe": {
@@ -138,15 +141,38 @@
{ {
"mode": "safe", "mode": "safe",
"enabled_component_count": 6, "enabled_component_count": 6,
"startup_ms": 0.399, "startup_ms": 0.419,
"full_lifespan_ms": 0.46, "full_lifespan_ms": 0.523,
"stage_ms": { "stage_ms": {
"HTTP 基础能力": 0.069, "HTTP 基础能力": 0.082,
"领域依赖装配": 0.035, "领域依赖装配": 0.04,
"数据库引擎预热": 0.027, "数据库引擎预热": 0.031,
"数据库连接预算": 0.029, "数据库连接预算": 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.024,
"模块服务": 0.026 "模块服务": 0.022,
"插件同步与启动收尾": 0.032
}, },
"threads_before": 2, "threads_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -159,36 +185,16 @@
{ {
"mode": "safe", "mode": "safe",
"enabled_component_count": 6, "enabled_component_count": 6,
"startup_ms": 0.382, "startup_ms": 0.41,
"full_lifespan_ms": 0.443, "full_lifespan_ms": 0.538,
"stage_ms": { "stage_ms": {
"HTTP 基础能力": 0.065, "HTTP 基础能力": 0.076,
"领域依赖装配": 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,
"领域依赖装配": 0.034, "领域依赖装配": 0.034,
"数据库引擎预热": 0.028, "数据库引擎预热": 0.028,
"数据库连接预算": 0.027, "数据库连接预算": 0.026,
"路由": 0.025, "路由": 0.025,
"模块服务": 0.025 "模块服务": 0.024,
"插件同步与启动收尾": 0.06
}, },
"threads_before": 2, "threads_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -199,8 +205,8 @@
"database_connections_started": 0 "database_connections_started": 0
} }
], ],
"median_startup_ms": 0.399, "median_startup_ms": 0.419,
"median_full_lifespan_ms": 0.46, "median_full_lifespan_ms": 0.538,
"enabled_component_count": 6 "enabled_component_count": 6
} }
} }
+2 -2
View File
@@ -435,7 +435,7 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
"prepare_database", "prepare_database",
lambda: calls.append("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() main.run_application()
@@ -469,7 +469,7 @@ def test_application_does_not_start_server_after_migration_failure(monkeypatch):
"prepare_database", "prepare_database",
MagicMock(side_effect=migration_error), 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: with pytest.raises(RuntimeError) as raised:
main.run_application() 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.signal, "signal", signal_handler)
monkeypatch.setattr(main, "start_tray", start_tray) monkeypatch.setattr(main, "start_tray", start_tray)
monkeypatch.setattr(main, "prepare_database", prepare_database) 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): with pytest.raises(UnsupportedProcessTopologyError):
main.run_application() 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