refactor: unify database readiness lifecycle

This commit is contained in:
jxxghp
2026-08-21 20:07:07 +08:00
parent bb57b22975
commit dd1c4c3279
24 changed files with 592 additions and 268 deletions
+45
View File
@@ -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,
)
+2 -1
View File
@@ -92,7 +92,8 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
# 设置WAL模式。 # 设置WAL模式。
# 这是引擎构建里唯一的阻塞 I/O,且发生在 get_engine() 的创建锁内——异步侧因此 # 这是引擎构建里唯一的阻塞 I/O,且发生在 get_engine() 的创建锁内——异步侧因此
# 移除了对称的那一段(见下方 else 分支)。同步侧保留是因为 journal_mode 必须有人 # 移除了对称的那一段(见下方 else 分支)。同步侧保留是因为 journal_mode 必须有人
# 设置一次,而同步引擎的首次创建由 init_db() 在启动期单线程完成,不存在一群线程 # 设置一次,而同步引擎的首次创建由 lifespan 数据库准备组件中的 init_db() 完成,
# 不存在一群线程
# 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。 # 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE" _journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
with engine.connect() as connection: with engine.connect() as connection:
+3
View File
@@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException
from app.api.response import ResponseAPIRoute from app.api.response import ResponseAPIRoute
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry 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.application.plugin.routes import configure_plugin_routes
from app.adapters.web.security.access import ( from app.adapters.web.security.access import (
configure_token_codec, configure_token_codec,
@@ -305,6 +306,8 @@ def create_app() -> FastAPI:
_app.add_exception_handler(Exception, localized_unhandled_exception_handler) _app.add_exception_handler(Exception, localized_unhandled_exception_handler)
# 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。 # 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。
_app.router.route_class = ResponseAPIRoute _app.router.route_class = ResponseAPIRoute
# 编排器探针使用原生 APIRoute 和最小响应,不进入业务响应包络或版本前缀。
install_health_routes(_app)
# 配置 CORS 中间件 # 配置 CORS 中间件
_app.add_middleware( _app.add_middleware(
-2
View File
@@ -60,7 +60,6 @@ from app.runtime.topology import (
UnsupportedProcessTopologyError, UnsupportedProcessTopologyError,
validate_process_topology, validate_process_topology,
) )
from app.startup.database_initializer import prepare_database
setproctitle.setproctitle(settings.PROJECT_NAME) setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -189,7 +188,6 @@ def run_application() -> None:
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
start_tray() start_tray()
prepare_database()
run_api_server() run_api_server()
+60
View File
@@ -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
+13 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy import inspect
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from app.runtime.config import settings 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.engine import get_engine
from app.db.models import load_all_models from app.db.models import load_all_models
from app.runtime.log import logger from app.runtime.log import logger
@@ -121,6 +121,18 @@ def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> Non
update_db(alembic_cfg) 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(): def init_db():
""" """
初始化数据库 初始化数据库
+44 -32
View File
@@ -27,6 +27,7 @@ except Exception:
from app.chain.system import SystemChain from app.chain.system import SystemChain
from app.application.plugin.runtime import get_plugin_manager from app.application.plugin.runtime import get_plugin_manager
from app.runtime.config import global_vars, settings 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.runtime.topology import validate_process_topology
from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper from app.runtime.state import SystemHelper
@@ -133,14 +134,34 @@ def prepare_plugin_restore() -> None:
SystemChain().restore_plugins() 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, ...]: def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
"""按现有顺序构建应用组件清单,回调在每次 lifespan 启动时重新绑定。""" """按现有顺序构建应用组件清单,回调在每次 lifespan 启动时重新绑定。"""
return ( return (
LifecycleComponent(
name="数据库准备",
start=lambda: prepare_database_component(app),
start_order=10,
start_timeout_seconds=300,
),
LifecycleComponent( LifecycleComponent(
name="HTTP 基础能力", name="HTTP 基础能力",
start=lambda: configure_default_user_agent(settings.USER_AGENT), start=lambda: configure_default_user_agent(settings.USER_AGENT),
stop=aclose_shared_async_transports, stop=aclose_shared_async_transports,
start_order=10, start_order=20,
stop_order=80, stop_order=80,
start_timeout_seconds=30, start_timeout_seconds=30,
stop_timeout_seconds=120, stop_timeout_seconds=120,
@@ -149,28 +170,28 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
name="领域依赖装配", name="领域依赖装配",
dependencies=("HTTP 基础能力",), dependencies=("HTTP 基础能力",),
start=configure_domain_dependencies, start=configure_domain_dependencies,
start_order=20, start_order=30,
start_timeout_seconds=30, start_timeout_seconds=30,
), ),
LifecycleComponent( LifecycleComponent(
name="数据库引擎预热", name="数据库引擎预热",
dependencies=("领域依赖装配",), dependencies=("数据库准备", "领域依赖装配"),
start=lambda: (get_engine(), get_global_async_engine()), start=lambda: (get_engine(), get_global_async_engine()),
start_order=30, start_order=40,
start_timeout_seconds=120, start_timeout_seconds=120,
), ),
LifecycleComponent( LifecycleComponent(
name="数据库连接预算", name="数据库连接预算",
dependencies=("数据库引擎预热",), dependencies=("数据库引擎预热",),
start=check_connection_budget, start=check_connection_budget,
start_order=40, start_order=50,
start_timeout_seconds=30, start_timeout_seconds=30,
), ),
LifecycleComponent( LifecycleComponent(
name="路由", name="路由",
dependencies=("数据库连接预算",), dependencies=("数据库连接预算",),
start=lambda: init_routers(app), start=lambda: init_routers(app),
start_order=50, start_order=60,
start_timeout_seconds=30, start_timeout_seconds=30,
), ),
LifecycleComponent( LifecycleComponent(
@@ -178,7 +199,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("路由",), dependencies=("路由",),
start=init_modules, start=init_modules,
stop=stop_modules, stop=stop_modules,
start_order=60, start_order=70,
stop_order=70, stop_order=70,
start_timeout_seconds=300, start_timeout_seconds=300,
stop_timeout_seconds=300, stop_timeout_seconds=300,
@@ -188,7 +209,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("模块服务",), dependencies=("模块服务",),
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=prepare_plugin_restore, start=prepare_plugin_restore,
start_order=70, start_order=80,
start_timeout_seconds=300, start_timeout_seconds=300,
), ),
LifecycleComponent( LifecycleComponent(
@@ -197,7 +218,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=init_plugins, start=init_plugins,
stop=stop_plugins, stop=stop_plugins,
start_order=80, start_order=90,
stop_order=60, stop_order=60,
start_timeout_seconds=300, start_timeout_seconds=300,
stop_timeout_seconds=300, stop_timeout_seconds=300,
@@ -208,7 +229,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=init_scheduler, start=init_scheduler,
stop=stop_scheduler, stop=stop_scheduler,
start_order=90, start_order=100,
stop_order=50, stop_order=50,
start_timeout_seconds=120, start_timeout_seconds=120,
stop_timeout_seconds=120, stop_timeout_seconds=120,
@@ -219,7 +240,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=init_monitor, start=init_monitor,
stop=stop_monitor, stop=stop_monitor,
start_order=100, start_order=110,
stop_order=40, stop_order=40,
start_timeout_seconds=120, start_timeout_seconds=120,
stop_timeout_seconds=120, stop_timeout_seconds=120,
@@ -229,7 +250,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("监控器",), dependencies=("监控器",),
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=replay_pending_transfers, start=replay_pending_transfers,
start_order=110, start_order=120,
start_timeout_seconds=30, start_timeout_seconds=30,
), ),
LifecycleComponent( LifecycleComponent(
@@ -238,7 +259,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=init_command, start=init_command,
stop=stop_command, stop=stop_command,
start_order=120, start_order=130,
stop_order=30, stop_order=30,
start_timeout_seconds=120, start_timeout_seconds=120,
stop_timeout_seconds=120, stop_timeout_seconds=120,
@@ -249,7 +270,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
mode=LifecycleMode.NORMAL_ONLY, mode=LifecycleMode.NORMAL_ONLY,
start=init_workflow, start=init_workflow,
stop=stop_workflow, stop=stop_workflow,
start_order=130, start_order=140,
stop_order=20, stop_order=20,
start_timeout_seconds=120, start_timeout_seconds=120,
stop_timeout_seconds=120, stop_timeout_seconds=120,
@@ -278,6 +299,9 @@ async def lifespan(app: FastAPI):
""" """
定义应用的生命周期事件 定义应用的生命周期事件
""" """
health = get_application_health(app)
health.begin_startup()
try:
validate_process_topology( validate_process_topology(
workers=settings.API_WORKERS, workers=settings.API_WORKERS,
safe_mode=settings.MOVIEPILOT_SAFE_MODE, safe_mode=settings.MOVIEPILOT_SAFE_MODE,
@@ -285,24 +309,6 @@ async def lifespan(app: FastAPI):
print("Starting up...") print("Starting up...")
# 存储当前循环 # 存储当前循环
global_vars.set_loop(asyncio.get_event_loop()) 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) components = build_lifecycle_components(app)
enabled_components = tuple( enabled_components = tuple(
component component
@@ -328,10 +334,16 @@ async def lifespan(app: FastAPI):
sync_plugins_task = asyncio.create_task( sync_plugins_task = asyncio.create_task(
run_startup_step("插件同步与启动收尾", init_extra) run_startup_step("插件同步与启动收尾", init_extra)
) )
health.mark_ready()
except BaseException:
# Uvicorn 在 lifespan 抛错时不会开始接流量;状态仍需供嵌入式入口和测试诊断。
health.mark_failed()
raise
try: try:
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环 # 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
yield yield
finally: finally:
health.mark_stopping()
print("Shutting down...") print("Shutting down...")
global_vars.stop_system() global_vars.stop_system()
# 插件恢复会在线程池中修改源码与依赖,必须完成后再进入资源关闭阶段。 # 插件恢复会在线程池中修改源码与依赖,必须完成后再进入资源关闭阶段。
+1 -1
View File
@@ -262,5 +262,5 @@ COPY --link --from=prepare_backend /app /app
EXPOSE 3000 EXPOSE 3000
VOLUME [ "${CONFIG_DIR}" ] 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" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]
+1 -1
View File
@@ -66,7 +66,7 @@ function wait_backend_ready() {
local backend_port="${PORT:-3001}" local backend_port="${PORT:-3001}"
local web_port="${NGINX_PORT:-3000}" local web_port="${NGINX_PORT:-3000}"
local timeout="${MOVIEPILOT_BACKEND_READY_TIMEOUT:-300}" 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 local deadline
if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then
WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。" WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。"
+8 -1
View File
@@ -210,10 +210,10 @@ sequenceDiagram
Factory->>Factory: create_app():异常处理器 / CORS / 本地化中间件 Factory->>Factory: create_app():异常处理器 / CORS / 本地化中间件
Factory->>Init: register_api_app(app) 注入插件路由服务 Factory->>Init: register_api_app(app) 注入插件路由服务
Factory-->>Life: lifespan 绑定到 app Factory-->>Life: lifespan 绑定到 app
Main->>Main: init_db() + update_db()Alembic 迁移)
Main->>FastAPI: Server.run() 触发 lifespan 启动 Main->>FastAPI: Server.run() 触发 lifespan 启动
Life->>Life: configure_cache_dependencies()<br/>(必须先于业务模块导入) Life->>Life: configure_cache_dependencies()<br/>(必须先于业务模块导入)
Life->>Init: prepare_database() + revision/head 校验
Life->>Init: configure_default_user_agent(注入 UA Life->>Init: configure_default_user_agent(注入 UA
Life->>Init: configure_domain_dependencies(领域层依赖注入) Life->>Init: configure_domain_dependencies(领域层依赖注入)
Life->>Init: get_engine() / get_global_async_engine() 预热 + fail-fast 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_plugins() / init_scheduler() / init_monitor()
Life->>Init: init_command() / init_workflow() Life->>Init: init_command() / init_workflow()
Life->>Init: replay_pending_transfers()(后台回放未整理文件) Life->>Init: replay_pending_transfers()(后台回放未整理文件)
Life->>Life: 发布 database_ready + lifecycle_ready
Life->>FastAPI: yield,交还控制权 Life->>FastAPI: yield,交还控制权
Note over Life,FastAPI: 运行期…… Note over Life,FastAPI: 运行期……
FastAPI->>Life: 收到停止信号 FastAPI->>Life: 收到停止信号
@@ -238,10 +239,16 @@ sequenceDiagram
- **Uvicorn 入口分流**:生产单 worker 使用带协作停止语义的 `MoviePilotServer`;开发 reload - **Uvicorn 入口分流**:生产单 worker 使用带协作停止语义的 `MoviePilotServer`;开发 reload
和安全模式多 worker 使用 `app.factory:create_app` import string/factory,由 supervisor 和安全模式多 worker 使用 `app.factory:create_app` import string/factory,由 supervisor
创建应用实例。`app.factory:app` 继续保留给既有 ASGI supervisor 和测试使用。 创建应用实例。`app.factory:app` 继续保留给既有 ASGI supervisor 和测试使用。
- **数据库准备唯一入口**:建表、迁移、迁移前备份和 Alembic head 校验统一由 lifespan
最早的“数据库准备”组件执行,`app.main` 不再主动迁移。主程序、外部 supervisor、factory
和 TestClient 因而共享同一 fail-fast 语义。
- **引擎预热 fail-fast**:同步/异步数据库引擎在单线程期完成首次创建, - **引擎预热 fail-fast**:同步/异步数据库引擎在单线程期完成首次创建,
避免调度器放出大量线程后再创建引擎导致连接锁竞争。 避免调度器放出大量线程后再创建引擎导致连接锁竞争。
- **安全模式**`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 - **安全模式**`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。
- **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。 - **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。
- **健康语义**`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库
到达当前 head 且生命周期完成后返回 200,启动失败或关停阶段返回 503。两者不公开路径、
revision、插件和异常详情,深入诊断继续使用 Doctor。
- **关停隔离**:每个关停步骤由 `run_shutdown_step` 独立捕获异常,保证后续资源仍有机会释放。 - **关停隔离**:每个关停步骤由 `run_shutdown_step` 独立捕获异常,保证后续资源仍有机会释放。
--- ---
+6
View File
@@ -92,6 +92,12 @@ MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。 Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。
镜像健康检查与 entrypoint 的后端就绪等待统一访问公开的 `/health/ready`:只有数据库迁移、
Alembic head 校验和生命周期启动完成后才返回 200;启动失败或关停时返回 503。单纯确认进程
和事件循环可响应可访问 `/health/live`。这两个探针无需 token,响应只包含最小状态,不提供
数据库路径、revision、插件名称或异常栈;详细原因应通过本地 `moviepilot doctor`、受控 Agent
诊断或管理员渠道查看。
## Issue 反馈集成 ## Issue 反馈集成
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。 `feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。
@@ -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-210211 已完成,后续任务按 ID 独立提交和回滚 > 实施进度:阶段 0ARCH-201203)与阶段 1ARCH-210212已完成,后续任务按 ID 独立提交和回滚
## 1. 结论先行 ## 1. 结论先行
+5 -5
View File
@@ -158,7 +158,7 @@ LOCAL_FRONTEND_SERVICE_SCRIPT = textwrap.dedent(
const backendHost = process.env.MOVIEPILOT_BACKEND_HOST || '127.0.0.1' const backendHost = process.env.MOVIEPILOT_BACKEND_HOST || '127.0.0.1'
const backendPort = Number(process.env.PORT || 3001) const backendPort = Number(process.env.PORT || 3001)
const frontendPort = Number(process.env.NGINX_PORT || 3000) 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 backendHealthTimeoutMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_TIMEOUT_MS || 3000)
const backendHealthIntervalMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_INTERVAL_MS || 15000) const backendHealthIntervalMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_INTERVAL_MS || 15000)
const backendMaxFailures = Math.max( const backendMaxFailures = Math.max(
@@ -2099,7 +2099,7 @@ def _load_auth_site_definitions_inner() -> dict[str, Any]:
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) 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 {} auth_sites = SitesHelper().get_authsites() or {}
definitions: dict[str, Any] = {} 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) system_config.set(SystemConfigKey.UserSiteAuthParams, site_auth_item)
try: 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( status, msg = SitesHelper().check_user(
site_auth_item.get("site"), site_auth_item.get("params") 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: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from app.runtime.config import settings from app.application.security.token import get_password_hash
from app.application.security.access import get_password_hash
from app.db.oper.user import UserOper from app.db.oper.user import UserOper
from app.runtime.config import settings
username = str(settings.SUPERUSER or "").strip() username = str(settings.SUPERUSER or "").strip()
username_error = _validate_superuser_name(username) username_error = _validate_superuser_name(username)
+1 -1
View File
@@ -800,7 +800,7 @@ def wait_for_ready(container, started_at: float, timeout: float) -> float:
"-fsS", "-fsS",
"--max-time", "--max-time",
"2", "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: while time.monotonic() < deadline:
if not container_running(container): if not container_running(container):
+15 -3
View File
@@ -105,17 +105,29 @@ async def _async_noop():
return None 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(): async def _probe():
lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r}
lifecycle.init_extra = _async_noop lifecycle.init_extra = _async_noop
lifecycle.global_vars.set_loop = lambda loop: None lifecycle.global_vars.set_loop = lambda loop: None
lifecycle.global_vars.stop_system = lambda: None lifecycle.global_vars.stop_system = lambda: None
lifecycle.LoggerManager.shutdown = 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( isolated_components = tuple(
dataclasses.replace( dataclasses.replace(
component, 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, stop=_noop if component.stop is not None else None,
) )
for component in original_components for component in original_components
@@ -134,7 +146,7 @@ async def _probe():
before_threads = threading.active_count() before_threads = threading.active_count()
before_tasks = len(asyncio.all_tasks()) before_tasks = len(asyncio.all_tasks())
started = time.perf_counter() started = time.perf_counter()
async with lifecycle.lifespan(FastAPI()): async with lifecycle.lifespan(probe_app):
startup_ms = (time.perf_counter() - started) * 1000 startup_ms = (time.perf_counter() - started) * 1000
started_threads = threading.active_count() started_threads = threading.active_count()
started_tasks = len(asyncio.all_tasks()) started_tasks = len(asyncio.all_tasks())
+11 -5
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6080, "edge_count": 6084,
"edge_sha256": "57d091e93986ca0d8e8a93a300c3c6d0eb0c2bfe92fda747cffd829cc3c25cab", "edge_sha256": "0dbce22b56284b845591fd38771a96c937ec898db7e9d1aece1ec47ceff8de04",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -151,6 +151,8 @@
"app.adapters.system.rust -> app.runtime", "app.adapters.system.rust -> app.runtime",
"app.adapters.system.rust -> app.runtime.config", "app.adapters.system.rust -> app.runtime.config",
"app.adapters.system.rust -> app.runtime.log", "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",
"app.adapters.web.security.access -> app.runtime.cache", "app.adapters.web.security.access -> app.runtime.cache",
"app.adapters.web.security.access -> app.runtime.config", "app.adapters.web.security.access -> app.runtime.config",
@@ -3608,6 +3610,7 @@
"app.domain.title -> app.schemas.types", "app.domain.title -> app.schemas.types",
"app.factory -> app.adapters", "app.factory -> app.adapters",
"app.factory -> app.adapters.web", "app.factory -> app.adapters.web",
"app.factory -> app.adapters.web.health",
"app.factory -> app.adapters.web.plugin", "app.factory -> app.adapters.web.plugin",
"app.factory -> app.adapters.web.plugin.routes", "app.factory -> app.adapters.web.plugin.routes",
"app.factory -> app.adapters.web.security", "app.factory -> app.adapters.web.security",
@@ -3639,8 +3642,6 @@
"app.main -> app.runtime", "app.main -> app.runtime",
"app.main -> app.runtime.config", "app.main -> app.runtime.config",
"app.main -> app.runtime.topology", "app.main -> app.runtime.topology",
"app.main -> app.startup",
"app.main -> app.startup.database_initializer",
"app.modules -> app.runtime", "app.modules -> app.runtime",
"app.modules -> app.runtime.extensions", "app.modules -> app.runtime.extensions",
"app.modules -> app.runtime.extensions.service_config", "app.modules -> app.runtime.extensions.service_config",
@@ -5713,6 +5714,7 @@
"app.startup.database -> app.runtime", "app.startup.database -> app.runtime",
"app.startup.database -> app.runtime.config", "app.startup.database -> app.runtime.config",
"app.startup.database_initializer -> app.db", "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.engine",
"app.startup.database_initializer -> app.db.models", "app.startup.database_initializer -> app.db.models",
"app.startup.database_initializer -> app.runtime", "app.startup.database_initializer -> app.runtime",
@@ -5750,12 +5752,14 @@
"app.startup.lifecycle -> app.db.engine", "app.startup.lifecycle -> app.db.engine",
"app.startup.lifecycle -> app.runtime", "app.startup.lifecycle -> app.runtime",
"app.startup.lifecycle -> app.runtime.config", "app.startup.lifecycle -> app.runtime.config",
"app.startup.lifecycle -> app.runtime.health",
"app.startup.lifecycle -> app.runtime.log", "app.startup.lifecycle -> app.runtime.log",
"app.startup.lifecycle -> app.runtime.state", "app.startup.lifecycle -> app.runtime.state",
"app.startup.lifecycle -> app.runtime.topology", "app.startup.lifecycle -> app.runtime.topology",
"app.startup.lifecycle -> app.startup", "app.startup.lifecycle -> app.startup",
"app.startup.lifecycle -> app.startup.cache_initializer", "app.startup.lifecycle -> app.startup.cache_initializer",
"app.startup.lifecycle -> app.startup.command_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.domain_initializer",
"app.startup.lifecycle -> app.startup.lifecycle.components", "app.startup.lifecycle -> app.startup.lifecycle.components",
"app.startup.lifecycle -> app.startup.modules_initializer", "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",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 754, "module_count": 756,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -6137,6 +6141,7 @@
"app.adapters.system.rust", "app.adapters.system.rust",
"app.adapters.system.stdio", "app.adapters.system.stdio",
"app.adapters.web", "app.adapters.web",
"app.adapters.web.health",
"app.adapters.web.plugin", "app.adapters.web.plugin",
"app.adapters.web.plugin.routes", "app.adapters.web.plugin.routes",
"app.adapters.web.security", "app.adapters.web.security",
@@ -6746,6 +6751,7 @@
"app.runtime.extensions.plugin_manager", "app.runtime.extensions.plugin_manager",
"app.runtime.extensions.service_config", "app.runtime.extensions.service_config",
"app.runtime.gc", "app.runtime.gc",
"app.runtime.health",
"app.runtime.localization", "app.runtime.localization",
"app.runtime.log", "app.runtime.log",
"app.runtime.managed_resources", "app.runtime.managed_resources",
+162 -156
View File
@@ -1,41 +1,41 @@
{ {
"schema_version": 1, "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", "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": 1809, "loaded_module_count": 1810,
"max_ms": 1023.29, "max_ms": 987.312,
"median_ms": 1018.356, "median_ms": 984.741,
"min_ms": 992.011, "min_ms": 983.917,
"samples_ms": [ "samples_ms": [
1023.29, 987.312,
1018.356, 984.741,
992.011 983.917
] ]
}, },
"app.factory": { "app.factory": {
"loaded_module_count": 1818, "loaded_module_count": 1820,
"max_ms": 1058.352, "max_ms": 1015.426,
"median_ms": 1048.27, "median_ms": 999.369,
"min_ms": 1045.441, "min_ms": 996.521,
"samples_ms": [ "samples_ms": [
1058.352, 996.521,
1048.27, 999.369,
1045.441 1015.426
] ]
}, },
"app.main": { "app.main": {
"loaded_module_count": 1961, "loaded_module_count": 1844,
"max_ms": 1316.051, "max_ms": 1057.647,
"median_ms": 1144.056, "median_ms": 1037.08,
"min_ms": 1138.523, "min_ms": 1034.011,
"samples_ms": [ "samples_ms": [
1138.523, 1057.647,
1316.051, 1034.011,
1144.056 1037.08
] ]
} }
}, },
@@ -46,132 +46,24 @@
"samples": [ "samples": [
{ {
"mode": "normal", "mode": "normal",
"enabled_component_count": 14, "enabled_component_count": 15,
"startup_ms": 0.566, "startup_ms": 0.569,
"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,
"full_lifespan_ms": 0.669, "full_lifespan_ms": 0.669,
"stage_ms": { "stage_ms": {
"HTTP 基础能力": 0.08, "数据库准备": 0.08,
"领域依赖装配": 0.036, "HTTP 基础能力": 0.035,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.029, "数据库引擎预热": 0.029,
"数据库连接预算": 0.027, "数据库连接预算": 0.025,
"路由": 0.027, "路由": 0.024,
"模块服务": 0.023, "模块服务": 0.024,
"插件备份恢复": 0.022, "插件备份恢复": 0.023,
"插件": 0.02, "插件": 0.021,
"定时器": 0.02, "定时器": 0.019,
"监控器": 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.022,
"待处理整理回放": 0.021, "待处理整理回放": 0.021,
"命令服务": 0.023, "命令服务": 0.019,
"工作流": 0.022, "工作流": 0.021,
"插件同步与启动收尾": 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.032 "插件同步与启动收尾": 0.032
}, },
"threads_before": 2, "threads_before": 2,
@@ -183,18 +75,56 @@
"database_connections_started": 0 "database_connections_started": 0
}, },
{ {
"mode": "safe", "mode": "normal",
"enabled_component_count": 6, "enabled_component_count": 15,
"startup_ms": 0.41, "startup_ms": 0.548,
"full_lifespan_ms": 0.538, "full_lifespan_ms": 0.641,
"stage_ms": { "stage_ms": {
"HTTP 基础能力": 0.076, "数据库准备": 0.071,
"领域依赖装配": 0.034, "HTTP 基础能力": 0.033,
"领域依赖装配": 0.032,
"数据库引擎预热": 0.028, "数据库引擎预热": 0.028,
"数据库连接预算": 0.026, "数据库连接预算": 0.025,
"路由": 0.025, "路由": 0.022,
"模块服务": 0.024, "模块服务": 0.023,
"插件同步与启动收尾": 0.06 "插件备份恢复": 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_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -205,9 +135,85 @@
"database_connections_started": 0 "database_connections_started": 0
} }
], ],
"median_startup_ms": 0.419, "median_startup_ms": 0.569,
"median_full_lifespan_ms": 0.538, "median_full_lifespan_ms": 0.669,
"enabled_component_count": 6 "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
} }
} }
} }
+57
View File
@@ -7,11 +7,14 @@ import uuid
import pytest import pytest
from alembic.util import CommandError from alembic.util import CommandError
from fastapi import FastAPI
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text
from sqlalchemy.engine.url import make_url from sqlalchemy.engine.url import make_url
from app.startup import database_initializer as db_init from app.startup import database_initializer as db_init
from app.startup import database as startup_database from app.startup import database as startup_database
from app.startup import lifecycle
from app.runtime.health import get_application_health
LOCAL_SETUP_PATH = ( 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( def test_prepare_database_creates_real_sqlite_restore_point_before_upgrade(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,
+2
View File
@@ -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 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile
assert "CMD /usr/bin/curl -fsS" 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 ( assert (
'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done' 'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done'
in dockerfile in dockerfile
+1 -1
View File
@@ -552,7 +552,7 @@ def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None:
) )
assert curl_log.read_text(encoding="utf-8") == ( 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 "MoviePilot Web 已可访问" in output
assert "后端就绪耗时" in output assert "后端就绪耗时" in output
+83
View File
@@ -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
)
+29 -23
View File
@@ -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"): for name in ("get_engine", "get_global_async_engine", "check_connection_budget"):
monkeypatch.setattr(lifecycle, name, MagicMock()) 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() system_chain = MagicMock()
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain)) 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()) asyncio.run(run_lifespan())
lifecycle.init_modules.assert_awaited_once_with() lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
lifecycle.configure_plugin_services.assert_called_once_with() lifecycle.configure_plugin_services.assert_called_once_with()
for name in ( for name in (
"init_plugins", "init_plugins",
@@ -195,6 +206,7 @@ def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
asyncio.run(run_lifespan()) asyncio.run(run_lifespan())
lifecycle.init_modules.assert_awaited_once_with() lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
for name in ( for name in (
"init_plugins", "init_plugins",
"init_scheduler", "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} safe_names = {item["name"] for item in safe}
assert normal_start == [ assert normal_start == [
"数据库准备",
"HTTP 基础能力", "HTTP 基础能力",
"领域依赖装配", "领域依赖装配",
"数据库引擎预热", "数据库引擎预热",
@@ -266,6 +279,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"HTTP 基础能力", "HTTP 基础能力",
] ]
assert safe_names == { assert safe_names == {
"数据库准备",
"HTTP 基础能力", "HTTP 基础能力",
"领域依赖装配", "领域依赖装配",
"数据库引擎预热", "数据库引擎预热",
@@ -324,11 +338,8 @@ def test_lifespan_creates_global_async_engine_at_startup(monkeypatch):
def test_lifespan_creates_sync_engine_at_startup(monkeypatch): def test_lifespan_creates_sync_engine_at_startup(monkeypatch):
"""启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期 """启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期
init_db() 会在启动期单线程预热同步引擎这个前提只对 run_application() 入口成立 数据库准备已统一进入 lifespan所有受支持 ASGI 入口都会先由 init_db() 创建同步引擎
外部 supervisor 直挂 ASGI app`gunicorn -k uvicorn.workers.UvicornWorker 随后的显式预热仍用于冻结顺序契约确保同步/异步引擎都早于 RouterModule 和后台线程
app.factory:app``uvicorn app.main:app` run_application() 不执行init_db() 也就
不执行同步引擎的首次创建退到运行期而那时 init_scheduler() / init_monitor() 已经
放出上百个线程引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上
""" """
_patch_lifespan(monkeypatch) _patch_lifespan(monkeypatch)
created = [] created = []
@@ -430,11 +441,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
lambda *_args: calls.append("signal"), lambda *_args: calls.append("signal"),
) )
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray")) 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")) monkeypatch.setattr(main, "run_api_server", lambda: calls.append("server"))
main.run_application() main.run_application()
@@ -444,7 +450,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
"signal", "signal",
"signal", "signal",
"tray", "tray",
"prepare_database",
"server", "server",
] ]
@@ -456,26 +461,27 @@ def test_asgi_and_main_entrypoints_share_the_same_app_instance():
assert main.app is factory.app assert main.app is factory.app
def test_application_does_not_start_server_after_migration_failure(monkeypatch): def test_lifespan_does_not_yield_after_migration_failure(monkeypatch):
"""数据库迁移失败时不得启动 API 服务""" """数据库迁移失败时 lifespan 必须 fail-fast 且不得发布 ready"""
from app import main
migration_error = RuntimeError("migration failed") migration_error = RuntimeError("migration failed")
server_run = MagicMock() _patch_lifespan(monkeypatch)
monkeypatch.setattr(main.signal, "signal", MagicMock())
monkeypatch.setattr(main, "start_tray", MagicMock())
monkeypatch.setattr( monkeypatch.setattr(
main, lifecycle,
"prepare_database", "prepare_database_component",
MagicMock(side_effect=migration_error), 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: with pytest.raises(RuntimeError) as raised:
main.run_application() asyncio.run(run_lifespan())
assert raised.value is migration_error 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): def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
-3
View File
@@ -56,11 +56,9 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False) monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False)
signal_handler = MagicMock() signal_handler = MagicMock()
start_tray = MagicMock() start_tray = MagicMock()
prepare_database = MagicMock()
server_run = MagicMock() server_run = MagicMock()
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, "run_api_server", server_run) monkeypatch.setattr(main, "run_api_server", server_run)
with pytest.raises(UnsupportedProcessTopologyError): with pytest.raises(UnsupportedProcessTopologyError):
@@ -68,7 +66,6 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
signal_handler.assert_not_called() signal_handler.assert_not_called()
start_tray.assert_not_called() start_tray.assert_not_called()
prepare_database.assert_not_called()
server_run.assert_not_called() server_run.assert_not_called()
+11
View File
@@ -8,6 +8,7 @@ import pytest
from app import factory, main from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError from app.runtime.topology import UnsupportedProcessTopologyError
from app.startup import lifecycle
PROJECT_ROOT = Path(__file__).parents[1] 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 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