diff --git a/app/adapters/web/health.py b/app/adapters/web/health.py new file mode 100644 index 000000000..1ef5a909a --- /dev/null +++ b/app/adapters/web/health.py @@ -0,0 +1,45 @@ +"""面向编排器的最小公开健康探针。""" + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute + +from app.runtime.health import get_application_health + + +async def liveness() -> JSONResponse: + """确认进程和当前事件循环能够处理请求,不访问任何外部依赖。""" + return JSONResponse(content={"status": "alive"}) + + +async def readiness(request: Request) -> JSONResponse: + """仅公开可否接流量,不泄露数据库、插件或启动异常细节。""" + ready = get_application_health(request.app).is_ready + return JSONResponse( + status_code=( + status.HTTP_200_OK + if ready + else status.HTTP_503_SERVICE_UNAVAILABLE + ), + content={"status": "ready" if ready else "not_ready"}, + ) + + +def install_health_routes(app: FastAPI) -> None: + """把公开探针装到 API 版本前缀之外,供容器和反向代理使用。""" + app.router.add_api_route( + "/health/live", + liveness, + methods=["GET"], + response_class=JSONResponse, + include_in_schema=False, + route_class_override=APIRoute, + ) + app.router.add_api_route( + "/health/ready", + readiness, + methods=["GET"], + response_class=JSONResponse, + include_in_schema=False, + route_class_override=APIRoute, + ) diff --git a/app/db/engine.py b/app/db/engine.py index 76013bfb8..76dd999e9 100644 --- a/app/db/engine.py +++ b/app/db/engine.py @@ -92,7 +92,8 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False): # 设置WAL模式。 # 这是引擎构建里唯一的阻塞 I/O,且发生在 get_engine() 的创建锁内——异步侧因此 # 移除了对称的那一段(见下方 else 分支)。同步侧保留是因为 journal_mode 必须有人 - # 设置一次,而同步引擎的首次创建由 init_db() 在启动期单线程完成,不存在一群线程 + # 设置一次,而同步引擎的首次创建由 lifespan 数据库准备组件中的 init_db() 完成, + # 不存在一群线程 # 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。 _journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE" with engine.connect() as connection: diff --git a/app/factory.py b/app/factory.py index d634311f2..8993c11ae 100644 --- a/app/factory.py +++ b/app/factory.py @@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException from app.api.response import ResponseAPIRoute from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry +from app.adapters.web.health import install_health_routes from app.application.plugin.routes import configure_plugin_routes from app.adapters.web.security.access import ( configure_token_codec, @@ -305,6 +306,8 @@ def create_app() -> FastAPI: _app.add_exception_handler(Exception, localized_unhandled_exception_handler) # 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。 _app.router.route_class = ResponseAPIRoute + # 编排器探针使用原生 APIRoute 和最小响应,不进入业务响应包络或版本前缀。 + install_health_routes(_app) # 配置 CORS 中间件 _app.add_middleware( diff --git a/app/main.py b/app/main.py index 06ebb3c6c..d350ebe6e 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,6 @@ from app.runtime.topology import ( UnsupportedProcessTopologyError, validate_process_topology, ) -from app.startup.database_initializer import prepare_database setproctitle.setproctitle(settings.PROJECT_NAME) @@ -189,7 +188,6 @@ def run_application() -> None: signal.signal(signal.SIGINT, signal_handler) start_tray() - prepare_database() run_api_server() diff --git a/app/runtime/health.py b/app/runtime/health.py new file mode 100644 index 000000000..8312ff7d7 --- /dev/null +++ b/app/runtime/health.py @@ -0,0 +1,60 @@ +"""进程内应用健康状态。""" + +from dataclasses import dataclass +from enum import StrEnum + +from fastapi import FastAPI + + +class ReadinessPhase(StrEnum): + """应用生命周期对公开 readiness 探针暴露的最小阶段。""" + + STARTING = "starting" + READY = "ready" + STOPPING = "stopping" + FAILED = "failed" + + +@dataclass(slots=True) +class ApplicationHealth: + """保存单个 FastAPI 实例的数据库和生命周期就绪状态。""" + + phase: ReadinessPhase = ReadinessPhase.STARTING + database_ready: bool = False + + @property + def is_ready(self) -> bool: + """仅在数据库和完整生命周期均成功后返回就绪。""" + return self.phase is ReadinessPhase.READY and self.database_ready + + def begin_startup(self) -> None: + """重置一次 lifespan 启动尝试的状态。""" + self.phase = ReadinessPhase.STARTING + self.database_ready = False + + def mark_database_ready(self) -> None: + """记录数据库迁移和 head 校验已经完成。""" + self.database_ready = True + + def mark_ready(self) -> None: + """记录所有 fail-fast 生命周期组件已经启动。""" + if not self.database_ready: + raise RuntimeError("数据库尚未就绪,不能发布应用 ready 状态") + self.phase = ReadinessPhase.READY + + def mark_failed(self) -> None: + """记录启动存在不可恢复失败。""" + self.phase = ReadinessPhase.FAILED + + def mark_stopping(self) -> None: + """在资源关闭前撤销 readiness。""" + self.phase = ReadinessPhase.STOPPING + + +def get_application_health(app: FastAPI) -> ApplicationHealth: + """读取应用级健康状态;为直接构造的测试应用补齐默认状态。""" + health = getattr(app.state, "moviepilot_health", None) + if health is None: + health = ApplicationHealth() + app.state.moviepilot_health = health + return health diff --git a/app/startup/database_initializer.py b/app/startup/database_initializer.py index c583c91aa..5146c925d 100644 --- a/app/startup/database_initializer.py +++ b/app/startup/database_initializer.py @@ -11,7 +11,7 @@ from sqlalchemy import inspect from sqlalchemy.engine import Engine from app.runtime.config import settings -from app.db import Base +from app.db.base import Base from app.db.engine import get_engine from app.db.models import load_all_models from app.runtime.log import logger @@ -121,6 +121,18 @@ def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> Non update_db(alembic_cfg) +def verify_database_revision() -> None: + """确认活动数据库已位于当前唯一 Alembic head,否则阻止 readiness。""" + engine = get_engine() + alembic_cfg = _build_alembic_config(engine) + _, current_heads, target_heads = _migration_state(engine, alembic_cfg) + if set(current_heads) != set(target_heads): + raise RuntimeError( + "数据库迁移完成后 revision 仍未到达当前 head:" + f"current={current_heads}, target={target_heads}" + ) + + def init_db(): """ 初始化数据库 diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 2dfea9634..a6b42f9cf 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -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.health import get_application_health from app.runtime.topology import validate_process_topology from app.adapters.external.server import MoviePilotServerHelper from app.runtime.state import SystemHelper @@ -133,14 +134,34 @@ def prepare_plugin_restore() -> None: SystemChain().restore_plugins() +def prepare_database_component(app: FastAPI) -> None: + """完成数据库建表、迁移与 head 校验后发布数据库就绪状态。""" + # Alembic 及全部 ORM 元数据只在 lifespan 真正启动时加载,create_app/import 阶段 + # 继续保持不建库、不加载迁移运行时的纯 ASGI 结构语义。 + from app.startup.database_initializer import ( + prepare_database, + verify_database_revision, + ) + + prepare_database() + verify_database_revision() + get_application_health(app).mark_database_ready() + + def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: """按现有顺序构建应用组件清单,回调在每次 lifespan 启动时重新绑定。""" return ( + LifecycleComponent( + name="数据库准备", + start=lambda: prepare_database_component(app), + start_order=10, + start_timeout_seconds=300, + ), LifecycleComponent( name="HTTP 基础能力", start=lambda: configure_default_user_agent(settings.USER_AGENT), stop=aclose_shared_async_transports, - start_order=10, + start_order=20, stop_order=80, start_timeout_seconds=30, stop_timeout_seconds=120, @@ -149,28 +170,28 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: name="领域依赖装配", dependencies=("HTTP 基础能力",), start=configure_domain_dependencies, - start_order=20, + start_order=30, start_timeout_seconds=30, ), LifecycleComponent( name="数据库引擎预热", - dependencies=("领域依赖装配",), + dependencies=("数据库准备", "领域依赖装配"), start=lambda: (get_engine(), get_global_async_engine()), - start_order=30, + start_order=40, start_timeout_seconds=120, ), LifecycleComponent( name="数据库连接预算", dependencies=("数据库引擎预热",), start=check_connection_budget, - start_order=40, + start_order=50, start_timeout_seconds=30, ), LifecycleComponent( name="路由", dependencies=("数据库连接预算",), start=lambda: init_routers(app), - start_order=50, + start_order=60, start_timeout_seconds=30, ), LifecycleComponent( @@ -178,7 +199,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: dependencies=("路由",), start=init_modules, stop=stop_modules, - start_order=60, + start_order=70, stop_order=70, start_timeout_seconds=300, stop_timeout_seconds=300, @@ -188,7 +209,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: dependencies=("模块服务",), mode=LifecycleMode.NORMAL_ONLY, start=prepare_plugin_restore, - start_order=70, + start_order=80, start_timeout_seconds=300, ), LifecycleComponent( @@ -197,7 +218,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: mode=LifecycleMode.NORMAL_ONLY, start=init_plugins, stop=stop_plugins, - start_order=80, + start_order=90, stop_order=60, start_timeout_seconds=300, stop_timeout_seconds=300, @@ -208,7 +229,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: mode=LifecycleMode.NORMAL_ONLY, start=init_scheduler, stop=stop_scheduler, - start_order=90, + start_order=100, stop_order=50, start_timeout_seconds=120, stop_timeout_seconds=120, @@ -219,7 +240,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: mode=LifecycleMode.NORMAL_ONLY, start=init_monitor, stop=stop_monitor, - start_order=100, + start_order=110, stop_order=40, start_timeout_seconds=120, stop_timeout_seconds=120, @@ -229,7 +250,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: dependencies=("监控器",), mode=LifecycleMode.NORMAL_ONLY, start=replay_pending_transfers, - start_order=110, + start_order=120, start_timeout_seconds=30, ), LifecycleComponent( @@ -238,7 +259,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: mode=LifecycleMode.NORMAL_ONLY, start=init_command, stop=stop_command, - start_order=120, + start_order=130, stop_order=30, start_timeout_seconds=120, stop_timeout_seconds=120, @@ -249,7 +270,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: mode=LifecycleMode.NORMAL_ONLY, start=init_workflow, stop=stop_workflow, - start_order=130, + start_order=140, stop_order=20, start_timeout_seconds=120, stop_timeout_seconds=120, @@ -278,60 +299,51 @@ 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()) - # 同步与异步引擎各预热一次。引擎改为惰性创建后,两者的首次创建时机都不再由启动路径 - # 决定,这一步把它们拉回来。必须排在所有 init_* 之前,两个理由: - # - # 其一,fail-fast 的落点。异步驱动缺失、异步 URL 拼错这类问题若不在这里暴露,会一路 - # 推迟到第一个异步查询——表现为用户请求 500 或调度任务静默失败,而不是启动即崩。 - # 故意不 try/except:起不来就该起不来,吞掉它等于把 fail-fast 又还回去了。而既然会抛, - # 就必须抛在 init_routers / init_modules 之前——下面的 try/finally 关停块要到 yield 处 - # 才开始,在它之后抛异常,已经初始化好的模块就拿不到 stop_modules() 了。 - # - # 其二,同步引擎的首次创建要落在单线程期。init_db() 会顺带预热它,但那只对 - # run_application() 入口成立;外部 supervisor 直挂 ASGI app(如 - # `gunicorn -k uvicorn.workers.UvicornWorker app.factory:app`)时 init_db() 根本不执行, - # 首次创建便退到运行期——而那时 init_scheduler() / init_monitor() 已经放出上百个线程, - # 引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。 - # - # 代价:异步侧几乎为零,create_async_engine 只校验 URL 与驱动导入、不建立连接;同步侧 - # 会连一次库、设一遍 journal mode,在事件循环上阻塞一小会儿——但那一次本来就免不了, - # 放在这里至少还独占着单线程,而且此刻 uvicorn 尚未开始接请求。 - components = build_lifecycle_components(app) - enabled_components = tuple( - component - for component in components - if component.enabled(settings.MOVIEPILOT_SAFE_MODE) - ) - logger.info( - "启用生命周期组件:%s", - ", ".join(component.name for component in enabled_components), - ) - for component in sorted( - (item for item in enabled_components if item.start is not None), - key=lambda item: item.start_order or 0, - ): - await run_startup_step( - component.name, - component.start, - component.start_timeout_seconds, + health = get_application_health(app) + health.begin_startup() + try: + validate_process_topology( + workers=settings.API_WORKERS, + safe_mode=settings.MOVIEPILOT_SAFE_MODE, ) - if settings.MOVIEPILOT_SAFE_MODE: - print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.") - # 插件同步到本地 - sync_plugins_task = asyncio.create_task( - run_startup_step("插件同步与启动收尾", init_extra) - ) + print("Starting up...") + # 存储当前循环 + global_vars.set_loop(asyncio.get_event_loop()) + components = build_lifecycle_components(app) + enabled_components = tuple( + component + for component in components + if component.enabled(settings.MOVIEPILOT_SAFE_MODE) + ) + logger.info( + "启用生命周期组件:%s", + ", ".join(component.name for component in enabled_components), + ) + for component in sorted( + (item for item in enabled_components if item.start is not None), + key=lambda item: item.start_order or 0, + ): + await run_startup_step( + component.name, + component.start, + component.start_timeout_seconds, + ) + if settings.MOVIEPILOT_SAFE_MODE: + print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.") + # 插件同步到本地 + sync_plugins_task = asyncio.create_task( + run_startup_step("插件同步与启动收尾", init_extra) + ) + health.mark_ready() + except BaseException: + # Uvicorn 在 lifespan 抛错时不会开始接流量;状态仍需供嵌入式入口和测试诊断。 + health.mark_failed() + raise try: # 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环 yield finally: + health.mark_stopping() print("Shutting down...") global_vars.stop_system() # 插件恢复会在线程池中修改源码与依赖,必须完成后再进入资源关闭阶段。 diff --git a/docker/Dockerfile b/docker/Dockerfile index e5e59be96..1c247f339 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -262,5 +262,5 @@ COPY --link --from=prepare_backend /app /app EXPOSE 3000 VOLUME [ "${CONFIG_DIR}" ] -HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/health/ready" >/dev/null || exit 1 ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4bf1b4bec..3e2ca14ec 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -66,7 +66,7 @@ function wait_backend_ready() { local backend_port="${PORT:-3001}" local web_port="${NGINX_PORT:-3000}" local timeout="${MOVIEPILOT_BACKEND_READY_TIMEOUT:-300}" - local ready_url="http://127.0.0.1:${backend_port}/api/v1/system/global?token=moviepilot" + local ready_url="http://127.0.0.1:${backend_port}/health/ready" local deadline if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index dd5d8f9d4..1e63b5972 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -210,10 +210,10 @@ sequenceDiagram Factory->>Factory: create_app():异常处理器 / CORS / 本地化中间件 Factory->>Init: register_api_app(app) 注入插件路由服务 Factory-->>Life: lifespan 绑定到 app - Main->>Main: init_db() + update_db()(Alembic 迁移) Main->>FastAPI: Server.run() 触发 lifespan 启动 Life->>Life: configure_cache_dependencies()
(必须先于业务模块导入) + Life->>Init: prepare_database() + revision/head 校验 Life->>Init: configure_default_user_agent(注入 UA) Life->>Init: configure_domain_dependencies(领域层依赖注入) Life->>Init: get_engine() / get_global_async_engine() 预热 + fail-fast @@ -223,6 +223,7 @@ sequenceDiagram Life->>Init: init_plugins() / init_scheduler() / init_monitor() Life->>Init: init_command() / init_workflow() Life->>Init: replay_pending_transfers()(后台回放未整理文件) + Life->>Life: 发布 database_ready + lifecycle_ready Life->>FastAPI: yield,交还控制权 Note over Life,FastAPI: 运行期…… FastAPI->>Life: 收到停止信号 @@ -238,10 +239,16 @@ sequenceDiagram - **Uvicorn 入口分流**:生产单 worker 使用带协作停止语义的 `MoviePilotServer`;开发 reload 和安全模式多 worker 使用 `app.factory:create_app` import string/factory,由 supervisor 创建应用实例。`app.factory:app` 继续保留给既有 ASGI supervisor 和测试使用。 +- **数据库准备唯一入口**:建表、迁移、迁移前备份和 Alembic head 校验统一由 lifespan + 最早的“数据库准备”组件执行,`app.main` 不再主动迁移。主程序、外部 supervisor、factory + 和 TestClient 因而共享同一 fail-fast 语义。 - **引擎预热 fail-fast**:同步/异步数据库引擎在单线程期完成首次创建, 避免调度器放出大量线程后再创建引擎导致连接锁竞争。 - **安全模式**:`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 - **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。 +- **健康语义**:`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库 + 到达当前 head 且生命周期完成后返回 200,启动失败或关停阶段返回 503。两者不公开路径、 + revision、插件和异常详情,深入诊断继续使用 Doctor。 - **关停隔离**:每个关停步骤由 `run_shutdown_step` 独立捕获异常,保证后续资源仍有机会释放。 --- diff --git a/docs/doctor.md b/docs/doctor.md index e6f44675f..61e30474a 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -92,6 +92,12 @@ MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。 +镜像健康检查与 entrypoint 的后端就绪等待统一访问公开的 `/health/ready`:只有数据库迁移、 +Alembic head 校验和生命周期启动完成后才返回 200;启动失败或关停时返回 503。单纯确认进程 +和事件循环可响应可访问 `/health/live`。这两个探针无需 token,响应只包含最小状态,不提供 +数据库路径、revision、插件名称或异常栈;详细原因应通过本地 `moviepilot doctor`、受控 Agent +诊断或管理员渠道查看。 + ## Issue 反馈集成 `feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index fc324fcb6..4b0fb350a 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~211 已完成,后续任务按 ID 独立提交和回滚 +> 实施进度:阶段 0(ARCH-201~203)与阶段 1(ARCH-210~212)已完成,后续任务按 ID 独立提交和回滚 ## 1. 结论先行 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index d6cb160d2..f80c8ed69 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -158,7 +158,7 @@ LOCAL_FRONTEND_SERVICE_SCRIPT = textwrap.dedent( const backendHost = process.env.MOVIEPILOT_BACKEND_HOST || '127.0.0.1' const backendPort = Number(process.env.PORT || 3001) const frontendPort = Number(process.env.NGINX_PORT || 3000) - const backendHealthPath = '/api/v1/system/global?token=moviepilot' + const backendHealthPath = '/health/ready' const backendHealthTimeoutMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_TIMEOUT_MS || 3000) const backendHealthIntervalMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_INTERVAL_MS || 15000) const backendMaxFailures = Math.max( @@ -2099,7 +2099,7 @@ def _load_auth_site_definitions_inner() -> dict[str, Any]: if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) - from app.application.site.sites import SitesHelper # noqa + from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module auth_sites = SitesHelper().get_authsites() or {} definitions: dict[str, Any] = {} @@ -2440,7 +2440,7 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None: ): system_config.set(SystemConfigKey.UserSiteAuthParams, site_auth_item) try: - from app.application.site.sites import SitesHelper # noqa + from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module status, msg = SitesHelper().check_user( site_auth_item.get("site"), site_auth_item.get("params") @@ -2472,9 +2472,9 @@ def _ensure_superuser_account_inner() -> None: if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) - from app.runtime.config import settings - from app.application.security.access import get_password_hash + from app.application.security.token import get_password_hash from app.db.oper.user import UserOper + from app.runtime.config import settings username = str(settings.SUPERUSER or "").strip() username_error = _validate_superuser_name(username) diff --git a/scripts/perf/moviepilot_docker_ab.py b/scripts/perf/moviepilot_docker_ab.py index 23af3a31b..63e98179d 100644 --- a/scripts/perf/moviepilot_docker_ab.py +++ b/scripts/perf/moviepilot_docker_ab.py @@ -800,7 +800,7 @@ def wait_for_ready(container, started_at: float, timeout: float) -> float: "-fsS", "--max-time", "2", - "http://127.0.0.1:3001/api/v1/system/global?token=moviepilot", + "http://127.0.0.1:3001/health/ready", ] while time.monotonic() < deadline: if not container_running(container): diff --git a/scripts/startup/performance.py b/scripts/startup/performance.py index 006c37b40..abe87199c 100644 --- a/scripts/startup/performance.py +++ b/scripts/startup/performance.py @@ -105,17 +105,29 @@ async def _async_noop(): return None +def _isolated_start(component, probe_app): + # 保留 readiness 所需状态转换,其余真实启动回调替换为空操作。 + if component.start is None: + return None + if component.name == '数据库准备': + return lambda: lifecycle.get_application_health( + probe_app + ).mark_database_ready() + return _noop + + async def _probe(): lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} lifecycle.init_extra = _async_noop lifecycle.global_vars.set_loop = lambda loop: None lifecycle.global_vars.stop_system = lambda: None lifecycle.LoggerManager.shutdown = lambda: None - original_components = lifecycle.build_lifecycle_components(FastAPI()) + probe_app = FastAPI() + original_components = lifecycle.build_lifecycle_components(probe_app) isolated_components = tuple( dataclasses.replace( component, - start=_noop if component.start is not None else None, + start=_isolated_start(component, probe_app), stop=_noop if component.stop is not None else None, ) for component in original_components @@ -134,7 +146,7 @@ async def _probe(): before_threads = threading.active_count() before_tasks = len(asyncio.all_tasks()) started = time.perf_counter() - async with lifecycle.lifespan(FastAPI()): + async with lifecycle.lifespan(probe_app): startup_ms = (time.perf_counter() - started) * 1000 started_threads = threading.active_count() started_tasks = len(asyncio.all_tasks()) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d4aadc746..5d3c9d5d9 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6080, - "edge_sha256": "57d091e93986ca0d8e8a93a300c3c6d0eb0c2bfe92fda747cffd829cc3c25cab", + "edge_count": 6084, + "edge_sha256": "0dbce22b56284b845591fd38771a96c937ec898db7e9d1aece1ec47ceff8de04", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -151,6 +151,8 @@ "app.adapters.system.rust -> app.runtime", "app.adapters.system.rust -> app.runtime.config", "app.adapters.system.rust -> app.runtime.log", + "app.adapters.web.health -> app.runtime", + "app.adapters.web.health -> app.runtime.health", "app.adapters.web.security.access -> app.runtime", "app.adapters.web.security.access -> app.runtime.cache", "app.adapters.web.security.access -> app.runtime.config", @@ -3608,6 +3610,7 @@ "app.domain.title -> app.schemas.types", "app.factory -> app.adapters", "app.factory -> app.adapters.web", + "app.factory -> app.adapters.web.health", "app.factory -> app.adapters.web.plugin", "app.factory -> app.adapters.web.plugin.routes", "app.factory -> app.adapters.web.security", @@ -3639,8 +3642,6 @@ "app.main -> app.runtime", "app.main -> app.runtime.config", "app.main -> app.runtime.topology", - "app.main -> app.startup", - "app.main -> app.startup.database_initializer", "app.modules -> app.runtime", "app.modules -> app.runtime.extensions", "app.modules -> app.runtime.extensions.service_config", @@ -5713,6 +5714,7 @@ "app.startup.database -> app.runtime", "app.startup.database -> app.runtime.config", "app.startup.database_initializer -> app.db", + "app.startup.database_initializer -> app.db.base", "app.startup.database_initializer -> app.db.engine", "app.startup.database_initializer -> app.db.models", "app.startup.database_initializer -> app.runtime", @@ -5750,12 +5752,14 @@ "app.startup.lifecycle -> app.db.engine", "app.startup.lifecycle -> app.runtime", "app.startup.lifecycle -> app.runtime.config", + "app.startup.lifecycle -> app.runtime.health", "app.startup.lifecycle -> app.runtime.log", "app.startup.lifecycle -> app.runtime.state", "app.startup.lifecycle -> app.runtime.topology", "app.startup.lifecycle -> app.startup", "app.startup.lifecycle -> app.startup.cache_initializer", "app.startup.lifecycle -> app.startup.command_initializer", + "app.startup.lifecycle -> app.startup.database_initializer", "app.startup.lifecycle -> app.startup.domain_initializer", "app.startup.lifecycle -> app.startup.lifecycle.components", "app.startup.lifecycle -> app.startup.modules_initializer", @@ -6097,7 +6101,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 754, + "module_count": 756, "modules": [ "app", "app.adapters", @@ -6137,6 +6141,7 @@ "app.adapters.system.rust", "app.adapters.system.stdio", "app.adapters.web", + "app.adapters.web.health", "app.adapters.web.plugin", "app.adapters.web.plugin.routes", "app.adapters.web.security", @@ -6746,6 +6751,7 @@ "app.runtime.extensions.plugin_manager", "app.runtime.extensions.service_config", "app.runtime.gc", + "app.runtime.health", "app.runtime.localization", "app.runtime.log", "app.runtime.managed_resources", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index a29ef9aca..ba949f20e 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-21T11:45:07.622632+00:00", + "generated_at": "2026-08-21T12:02:30.246931+00:00", "platform": "macOS-26.5.2-arm64-arm-64bit", "python": "3.12.6", "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_module_count": 1809, - "max_ms": 1023.29, - "median_ms": 1018.356, - "min_ms": 992.011, + "loaded_module_count": 1810, + "max_ms": 987.312, + "median_ms": 984.741, + "min_ms": 983.917, "samples_ms": [ - 1023.29, - 1018.356, - 992.011 + 987.312, + 984.741, + 983.917 ] }, "app.factory": { - "loaded_module_count": 1818, - "max_ms": 1058.352, - "median_ms": 1048.27, - "min_ms": 1045.441, + "loaded_module_count": 1820, + "max_ms": 1015.426, + "median_ms": 999.369, + "min_ms": 996.521, "samples_ms": [ - 1058.352, - 1048.27, - 1045.441 + 996.521, + 999.369, + 1015.426 ] }, "app.main": { - "loaded_module_count": 1961, - "max_ms": 1316.051, - "median_ms": 1144.056, - "min_ms": 1138.523, + "loaded_module_count": 1844, + "max_ms": 1057.647, + "median_ms": 1037.08, + "min_ms": 1034.011, "samples_ms": [ - 1138.523, - 1316.051, - 1144.056 + 1057.647, + 1034.011, + 1037.08 ] } }, @@ -46,132 +46,24 @@ "samples": [ { "mode": "normal", - "enabled_component_count": 14, - "startup_ms": 0.566, - "full_lifespan_ms": 0.67, - "stage_ms": { - "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, + "enabled_component_count": 15, + "startup_ms": 0.569, "full_lifespan_ms": 0.669, "stage_ms": { - "HTTP 基础能力": 0.08, - "领域依赖装配": 0.036, + "数据库准备": 0.08, + "HTTP 基础能力": 0.035, + "领域依赖装配": 0.028, "数据库引擎预热": 0.029, - "数据库连接预算": 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.025, + "路由": 0.024, + "模块服务": 0.024, + "插件备份恢复": 0.023, + "插件": 0.021, + "定时器": 0.019, "监控器": 0.022, "待处理整理回放": 0.021, - "命令服务": 0.023, - "工作流": 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 - } - ], - "median_startup_ms": 0.571, - "median_full_lifespan_ms": 0.67, - "enabled_component_count": 14 - }, - "safe": { - "samples": [ - { - "mode": "safe", - "enabled_component_count": 6, - "startup_ms": 0.419, - "full_lifespan_ms": 0.523, - "stage_ms": { - "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.022, + "命令服务": 0.019, + "工作流": 0.021, "插件同步与启动收尾": 0.032 }, "threads_before": 2, @@ -183,18 +75,56 @@ "database_connections_started": 0 }, { - "mode": "safe", - "enabled_component_count": 6, - "startup_ms": 0.41, - "full_lifespan_ms": 0.538, + "mode": "normal", + "enabled_component_count": 15, + "startup_ms": 0.548, + "full_lifespan_ms": 0.641, "stage_ms": { - "HTTP 基础能力": 0.076, - "领域依赖装配": 0.034, + "数据库准备": 0.071, + "HTTP 基础能力": 0.033, + "领域依赖装配": 0.032, "数据库引擎预热": 0.028, - "数据库连接预算": 0.026, - "路由": 0.025, - "模块服务": 0.024, - "插件同步与启动收尾": 0.06 + "数据库连接预算": 0.025, + "路由": 0.022, + "模块服务": 0.023, + "插件备份恢复": 0.019, + "插件": 0.02, + "定时器": 0.022, + "监控器": 0.024, + "待处理整理回放": 0.019, + "命令服务": 0.022, + "工作流": 0.022, + "插件同步与启动收尾": 0.028 + }, + "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": 15, + "startup_ms": 0.687, + "full_lifespan_ms": 0.795, + "stage_ms": { + "数据库准备": 0.083, + "HTTP 基础能力": 0.035, + "领域依赖装配": 0.028, + "数据库引擎预热": 0.033, + "数据库连接预算": 0.027, + "路由": 0.021, + "模块服务": 0.019, + "插件备份恢复": 0.064, + "插件": 0.032, + "定时器": 0.025, + "监控器": 0.028, + "待处理整理回放": 0.025, + "命令服务": 0.024, + "工作流": 0.024, + "插件同步与启动收尾": 0.037 }, "threads_before": 2, "threads_started": 2, @@ -205,9 +135,85 @@ "database_connections_started": 0 } ], - "median_startup_ms": 0.419, - "median_full_lifespan_ms": 0.538, - "enabled_component_count": 6 + "median_startup_ms": 0.569, + "median_full_lifespan_ms": 0.669, + "enabled_component_count": 15 + }, + "safe": { + "samples": [ + { + "mode": "safe", + "enabled_component_count": 7, + "startup_ms": 0.408, + "full_lifespan_ms": 0.531, + "stage_ms": { + "数据库准备": 0.077, + "HTTP 基础能力": 0.035, + "领域依赖装配": 0.028, + "数据库引擎预热": 0.026, + "数据库连接预算": 0.024, + "路由": 0.024, + "模块服务": 0.024, + "插件同步与启动收尾": 0.057 + }, + "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": 7, + "startup_ms": 0.416, + "full_lifespan_ms": 0.534, + "stage_ms": { + "数据库准备": 0.079, + "HTTP 基础能力": 0.036, + "领域依赖装配": 0.029, + "数据库引擎预热": 0.027, + "数据库连接预算": 0.026, + "路由": 0.026, + "模块服务": 0.021, + "插件同步与启动收尾": 0.054 + }, + "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": 7, + "startup_ms": 0.397, + "full_lifespan_ms": 0.52, + "stage_ms": { + "数据库准备": 0.07, + "HTTP 基础能力": 0.033, + "领域依赖装配": 0.026, + "数据库引擎预热": 0.027, + "数据库连接预算": 0.029, + "路由": 0.024, + "模块服务": 0.022, + "插件同步与启动收尾": 0.051 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + } + ], + "median_startup_ms": 0.408, + "median_full_lifespan_ms": 0.531, + "enabled_component_count": 7 } } } diff --git a/tests/test_database_migration_startup.py b/tests/test_database_migration_startup.py index f043538e6..80bcb4d1f 100644 --- a/tests/test_database_migration_startup.py +++ b/tests/test_database_migration_startup.py @@ -7,11 +7,14 @@ import uuid import pytest from alembic.util import CommandError +from fastapi import FastAPI from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text from sqlalchemy.engine.url import make_url from app.startup import database_initializer as db_init from app.startup import database as startup_database +from app.startup import lifecycle +from app.runtime.health import get_application_health LOCAL_SETUP_PATH = ( @@ -272,6 +275,60 @@ def test_migration_lineage_wraps_unknown_revision() -> None: ) +def test_verify_database_revision_requires_current_head(monkeypatch) -> None: + """readiness 的数据库校验必须拒绝升级后仍未到 head 的状态。""" + engine = object() + config = object() + monkeypatch.setattr(db_init, "get_engine", lambda: engine) + monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config) + monkeypatch.setattr( + db_init, + "_migration_state", + lambda *_: (True, ("old",), ("head",)), + ) + + with pytest.raises(RuntimeError, match="仍未到达当前 head"): + db_init.verify_database_revision() + + +def test_verify_database_revision_accepts_current_head(monkeypatch) -> None: + """活动 revision 与唯一目标 head 一致时允许发布数据库就绪。""" + engine = object() + config = object() + monkeypatch.setattr(db_init, "get_engine", lambda: engine) + monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config) + monkeypatch.setattr( + db_init, + "_migration_state", + lambda *_: (True, ("head",), ("head",)), + ) + + db_init.verify_database_revision() + + +def test_lifecycle_database_component_marks_ready_after_head_check( + monkeypatch, +) -> None: + """数据库组件必须按迁移、head 校验、发布状态的顺序执行。""" + app = FastAPI() + calls: list[str] = [] + monkeypatch.setattr( + db_init, + "prepare_database", + lambda: calls.append("prepare"), + ) + monkeypatch.setattr( + db_init, + "verify_database_revision", + lambda: calls.append("verify"), + ) + + lifecycle.prepare_database_component(app) + + assert calls == ["prepare", "verify"] + assert get_application_health(app).database_ready is True + + def test_prepare_database_creates_real_sqlite_restore_point_before_upgrade( tmp_path: Path, monkeypatch, diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 3fcca7443..14b593161 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -50,6 +50,8 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None: ) assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile assert "CMD /usr/bin/curl -fsS" in dockerfile + assert '"http://127.0.0.1:${PORT:-3001}/health/ready"' in dockerfile + assert "system/global?token=moviepilot" not in dockerfile assert ( 'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done' in dockerfile diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index aa658561f..4b981427b 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -552,7 +552,7 @@ def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None: ) assert curl_log.read_text(encoding="utf-8") == ( - "-fsS --max-time 2 http://127.0.0.1:4321/api/v1/system/global?token=moviepilot\n" + "-fsS --max-time 2 http://127.0.0.1:4321/health/ready\n" ) assert "MoviePilot Web 已可访问" in output assert "后端就绪耗时" in output diff --git a/tests/test_health_probes.py b/tests/test_health_probes.py new file mode 100644 index 000000000..af4002637 --- /dev/null +++ b/tests/test_health_probes.py @@ -0,0 +1,83 @@ +"""公开 liveness/readiness 探针契约测试。""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.factory import create_app +from app.runtime.health import ReadinessPhase, get_application_health + + +PROJECT_ROOT = Path(__file__).parents[1] + + +def test_liveness_is_public_minimal_and_independent_from_readiness() -> None: + """存活探针不访问依赖,即使启动失败也只报告事件循环仍可响应。""" + app = create_app() + get_application_health(app).mark_failed() + + response = TestClient(app).get("/health/live") + + assert response.status_code == 200 + assert response.json() == {"status": "alive"} + + +def test_readiness_is_unavailable_before_database_and_lifecycle_complete() -> None: + """尚未进入 lifespan 或启动失败时不得让编排器接入流量。""" + app = create_app() + client = TestClient(app) + + response = client.get("/health/ready") + + assert response.status_code == 503 + assert response.json() == {"status": "not_ready"} + + +def test_readiness_only_exposes_ready_after_database_and_lifecycle() -> None: + """数据库 head 校验和完整启动都成功后才返回最小 ready 状态。""" + app = create_app() + health = get_application_health(app) + health.mark_database_ready() + health.mark_ready() + + response = TestClient(app).get("/health/ready") + + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + + health.mark_stopping() + stopped = TestClient(app).get("/health/ready") + assert stopped.status_code == 503 + assert stopped.json() == {"status": "not_ready"} + assert health.phase is ReadinessPhase.STOPPING + + +def test_health_routes_are_public_but_hidden_from_business_openapi() -> None: + """基础设施探针无需鉴权,但不扩大业务 OpenAPI 的公开契约。""" + app = create_app() + client = TestClient(app) + + assert client.get("/health/live").status_code == 200 + schema = client.get("/api/v1/openapi.json").json() + assert "/health/live" not in schema["paths"] + assert "/health/ready" not in schema["paths"] + + +def test_operational_consumers_use_readiness_probe() -> None: + """容器、本地前端和性能工具不得再借业务接口判断启动完成。""" + paths = ( + "docker/Dockerfile", + "docker/entrypoint.sh", + "scripts/local_setup.py", + "scripts/perf/moviepilot_docker_ab.py", + ) + contents = [ + (PROJECT_ROOT / relative_path).read_text(encoding="utf-8") + for relative_path in paths + ] + + assert all("/health/ready" in content for content in contents) + assert all( + "system/global?token=moviepilot" not in content + for content in contents + ) diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index e78df0020..c8c3a03ab 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -41,6 +41,16 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict: # 自洽了,而且额度核算还会去连库。 for name in ("get_engine", "get_global_async_engine", "check_connection_budget"): monkeypatch.setattr(lifecycle, name, MagicMock()) + database_prepare = MagicMock( + side_effect=lambda app: lifecycle.get_application_health( + app + ).mark_database_ready() + ) + monkeypatch.setattr( + lifecycle, + "prepare_database_component", + database_prepare, + ) system_chain = MagicMock() monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain)) @@ -125,6 +135,7 @@ def test_lifespan_normal_mode_starts_full_runtime(monkeypatch): asyncio.run(run_lifespan()) lifecycle.init_modules.assert_awaited_once_with() + lifecycle.prepare_database_component.assert_called_once() lifecycle.configure_plugin_services.assert_called_once_with() for name in ( "init_plugins", @@ -195,6 +206,7 @@ def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch): asyncio.run(run_lifespan()) lifecycle.init_modules.assert_awaited_once_with() + lifecycle.prepare_database_component.assert_called_once() for name in ( "init_plugins", "init_scheduler", @@ -241,6 +253,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: safe_names = {item["name"] for item in safe} assert normal_start == [ + "数据库准备", "HTTP 基础能力", "领域依赖装配", "数据库引擎预热", @@ -266,6 +279,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: "HTTP 基础能力", ] assert safe_names == { + "数据库准备", "HTTP 基础能力", "领域依赖装配", "数据库引擎预热", @@ -324,11 +338,8 @@ def test_lifespan_creates_global_async_engine_at_startup(monkeypatch): def test_lifespan_creates_sync_engine_at_startup(monkeypatch): """启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期 - 「init_db() 会在启动期单线程预热同步引擎」这个前提只对 run_application() 入口成立。 - 外部 supervisor 直挂 ASGI app(`gunicorn -k uvicorn.workers.UvicornWorker - app.factory:app`、`uvicorn app.main:app`)时 run_application() 不执行、init_db() 也就 - 不执行,同步引擎的首次创建退到运行期——而那时 init_scheduler() / init_monitor() 已经 - 放出上百个线程,引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。 + 数据库准备已统一进入 lifespan,所有受支持 ASGI 入口都会先由 init_db() 创建同步引擎; + 随后的显式预热仍用于冻结顺序契约,确保同步/异步引擎都早于 Router、Module 和后台线程。 """ _patch_lifespan(monkeypatch) created = [] @@ -430,11 +441,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch): lambda *_args: calls.append("signal"), ) monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray")) - monkeypatch.setattr( - main, - "prepare_database", - lambda: calls.append("prepare_database"), - ) monkeypatch.setattr(main, "run_api_server", lambda: calls.append("server")) main.run_application() @@ -444,7 +450,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch): "signal", "signal", "tray", - "prepare_database", "server", ] @@ -456,26 +461,27 @@ def test_asgi_and_main_entrypoints_share_the_same_app_instance(): assert main.app is factory.app -def test_application_does_not_start_server_after_migration_failure(monkeypatch): - """数据库迁移失败时不得启动 API 服务。""" - from app import main - +def test_lifespan_does_not_yield_after_migration_failure(monkeypatch): + """数据库迁移失败时 lifespan 必须 fail-fast 且不得发布 ready。""" migration_error = RuntimeError("migration failed") - server_run = MagicMock() - monkeypatch.setattr(main.signal, "signal", MagicMock()) - monkeypatch.setattr(main, "start_tray", MagicMock()) + _patch_lifespan(monkeypatch) monkeypatch.setattr( - main, - "prepare_database", + lifecycle, + "prepare_database_component", MagicMock(side_effect=migration_error), ) - monkeypatch.setattr(main, "run_api_server", server_run) + app = FastAPI() + + async def run_lifespan(): + async with lifecycle.lifespan(app): + pytest.fail("数据库迁移失败后不应进入服务阶段") with pytest.raises(RuntimeError) as raised: - main.run_application() + asyncio.run(run_lifespan()) assert raised.value is migration_error - server_run.assert_not_called() + assert app.state.moviepilot_health.is_ready is False + assert app.state.moviepilot_health.phase.value == "failed" def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch): diff --git a/tests/test_process_topology.py b/tests/test_process_topology.py index cdeb5ff1d..70d6f0437 100644 --- a/tests/test_process_topology.py +++ b/tests/test_process_topology.py @@ -56,11 +56,9 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch): monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False) signal_handler = MagicMock() start_tray = MagicMock() - prepare_database = MagicMock() server_run = MagicMock() monkeypatch.setattr(main.signal, "signal", signal_handler) monkeypatch.setattr(main, "start_tray", start_tray) - monkeypatch.setattr(main, "prepare_database", prepare_database) monkeypatch.setattr(main, "run_api_server", server_run) with pytest.raises(UnsupportedProcessTopologyError): @@ -68,7 +66,6 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch): signal_handler.assert_not_called() start_tray.assert_not_called() - prepare_database.assert_not_called() server_run.assert_not_called() diff --git a/tests/test_uvicorn_entrypoint.py b/tests/test_uvicorn_entrypoint.py index a298b59c1..9479d391f 100644 --- a/tests/test_uvicorn_entrypoint.py +++ b/tests/test_uvicorn_entrypoint.py @@ -8,6 +8,7 @@ import pytest from app import factory, main from app.runtime.topology import UnsupportedProcessTopologyError +from app.startup import lifecycle PROJECT_ROOT = Path(__file__).parents[1] @@ -124,3 +125,13 @@ def test_local_launcher_keeps_module_entrypoint(): ) assert 'exec "$VENV_PYTHON" -m app.main' in script + + +def test_supported_asgi_entries_share_database_lifecycle() -> None: + """主程序、factory 与 TestClient 应通过同一 lifespan 准备数据库。""" + created = factory.create_app() + + assert factory.app.router.lifespan_context is lifecycle.lifespan + assert main.app.router.lifespan_context is lifecycle.lifespan + assert created.router.lifespan_context is lifecycle.lifespan + assert "prepare_database" not in main.run_application.__code__.co_names