refactor: enforce single-worker control plane

This commit is contained in:
jxxghp
2026-08-21 19:40:20 +08:00
parent 6c7c54d2c1
commit d89d29612c
11 changed files with 231 additions and 7 deletions
+45
View File
@@ -19,6 +19,7 @@ from urllib.request import Request, urlopen
import psutil
from app.runtime.config import settings
from app.runtime.topology import process_topology_issue
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorReport, DoctorSeverity
from app.adapters.system.host import SystemUtils
@@ -134,6 +135,7 @@ def default_checks() -> list[CheckFunc]:
return [
_check_runtime_paths,
_check_config,
_check_process_topology,
_check_processes_and_ports,
_check_dependencies,
_check_database,
@@ -154,6 +156,49 @@ def _mask_text(text: str) -> str:
return masked
def _check_process_topology(runner: DoctorRunnerProtocol) -> None:
"""诊断 API worker 配置是否会复制全功能控制面。"""
issue = process_topology_issue(
workers=settings.API_WORKERS,
safe_mode=settings.MOVIEPILOT_SAFE_MODE,
)
context = {
"api_workers": settings.API_WORKERS,
"safe_mode": settings.MOVIEPILOT_SAFE_MODE,
}
if issue:
runner.add(
finding_id="startup.process_topology",
severity=DoctorSeverity.Error,
status=DoctorFindingStatus.Failed,
title="进程拓扑不受支持",
detail=issue,
recommendation="将 API_WORKERS 设为 1 后重启 MoviePilot。",
context=context,
)
return
if settings.API_WORKERS != 1:
runner.add(
finding_id="startup.process_topology",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="安全模式临时使用多 worker",
detail="安全模式不会启动插件、调度器、监控器和工作流,因此当前不会复制完整控制面。",
recommendation="故障排除后恢复 API_WORKERS=1,再退出安全模式。",
context=context,
)
return
runner.add(
finding_id="startup.process_topology",
severity=DoctorSeverity.Info,
status=DoctorFindingStatus.Ok,
title="进程拓扑受支持",
detail="API_WORKERS=1,插件和后台任务只会启动一份。",
recommendation="保持单 worker;扩容前需要先拆分 API 数据面和控制面。",
context=context,
)
def _read_json(path: Path) -> Optional[dict[str, Any]]:
if not path.exists():
return None
+5
View File
@@ -55,6 +55,7 @@ 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.startup.database_initializer import prepare_database
setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -133,6 +134,10 @@ def signal_handler(signum, frame):
def run_application() -> None:
"""初始化进程并启动 API 服务"""
validate_process_topology(
workers=settings.API_WORKERS,
safe_mode=settings.MOVIEPILOT_SAFE_MODE,
)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
+1 -1
View File
@@ -134,7 +134,7 @@ class ConfigModel(BaseModel):
# 数据库连接额度校验按它换算总用量。注意:当前主程序以单进程方式启动
# uvicorn.Config 的 workers 仅在多进程 supervisor 路径下生效),
# 调大此项前需先解决调度器会在每个 worker 内重复执行的问题
API_WORKERS: int = 1
API_WORKERS: int = Field(default=1, ge=1)
DB_TYPE: str = "sqlite"
# 是否在控制台输出 SQL 语句,默认关闭
DB_ECHO: bool = False
+40
View File
@@ -0,0 +1,40 @@
"""MoviePilot 进程拓扑约束。"""
from typing import Optional
class UnsupportedProcessTopologyError(RuntimeError):
"""启动配置会复制当前只能单实例运行的控制面。"""
def process_topology_issue(*, workers: int, safe_mode: bool) -> Optional[str]:
"""
返回当前进程拓扑不可运行的原因。
:param workers: API worker 进程数
:param safe_mode: 是否只启动安全模式数据面
:return: 支持时返回 None,否则返回可直接展示的错误说明
"""
if workers < 1:
return "API_WORKERS 必须大于等于 1"
if workers == 1 or safe_mode:
return None
return (
"MoviePilot V3 全功能模式仅支持 API_WORKERS=1"
f"当前配置为 {workers},每个 worker 都会重复启动插件、调度器、监控器和工作流。"
"请将 API_WORKERS 改为 1 后重启。故障排查可以临时启用 "
"MOVIEPILOT_SAFE_MODE=true,但安全模式不是全功能扩容方案。"
)
def validate_process_topology(*, workers: int, safe_mode: bool) -> None:
"""
在启动任何持久化或后台副作用前校验进程拓扑。
:param workers: API worker 进程数
:param safe_mode: 是否只启动安全模式数据面
:raises UnsupportedProcessTopologyError: 当前拓扑会复制全功能控制面
"""
issue = process_topology_issue(workers=workers, safe_mode=safe_mode)
if issue:
raise UnsupportedProcessTopologyError(issue)
+6 -1
View File
@@ -27,6 +27,7 @@ except Exception:
from app.chain.system import SystemChain
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.config import global_vars, settings
from app.runtime.topology import validate_process_topology
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper
from app.runtime.log import logger, LoggerManager
@@ -47,7 +48,7 @@ from app.startup.scheduler_initializer import (
init_scheduler,
init_plugin_scheduler,
)
from app.db import check_connection_budget, get_engine, get_global_async_engine
from app.db.engine import check_connection_budget, get_engine, get_global_async_engine
from app.startup.transfer_initializer import replay_pending_transfers
from app.startup.workflow_initializer import init_workflow, stop_workflow
from app.startup.lifecycle.components import (
@@ -277,6 +278,10 @@ async def lifespan(app: FastAPI):
"""
定义应用的生命周期事件
"""
validate_process_topology(
workers=settings.API_WORKERS,
safe_mode=settings.MOVIEPILOT_SAFE_MODE,
)
print("Starting up...")
# 存储当前循环
global_vars.set_loop(asyncio.get_event_loop())