diff --git a/app/main.py b/app/main.py index a02500947..06ebb3c6c 100644 --- a/app/main.py +++ b/app/main.py @@ -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__': diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index cd302d866..dd5d8f9d4 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 diff --git a/docs/development-setup.md b/docs/development-setup.md index 0800b7b66..08ae17c84 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -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. 参考资源 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 1ce3df85c..fc324fcb6 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -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` -> 实施进度:阶段 0(ARCH-201~203)与 ARCH-210 已完成,后续任务按 ID 独立提交和回滚 +> 实施进度:阶段 0(ARCH-201~203)与 ARCH-210~211 已完成,后续任务按 ID 独立提交和回滚 ## 1. 结论先行 diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index f4d96c70c..a29ef9aca 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -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 } } diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 9f8de7c6a..e78df0020 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -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() diff --git a/tests/test_process_topology.py b/tests/test_process_topology.py index 78795ee76..cdeb5ff1d 100644 --- a/tests/test_process_topology.py +++ b/tests/test_process_topology.py @@ -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() diff --git a/tests/test_uvicorn_entrypoint.py b/tests/test_uvicorn_entrypoint.py new file mode 100644 index 000000000..a298b59c1 --- /dev/null +++ b/tests/test_uvicorn_entrypoint.py @@ -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