mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: unify database readiness lifecycle
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
"""
|
||||
初始化数据库
|
||||
|
||||
@@ -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()
|
||||
# 插件恢复会在线程池中修改源码与依赖,必须完成后再进入资源关闭阶段。
|
||||
|
||||
+1
-1
@@ -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" ]
|
||||
|
||||
@@ -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 秒。"
|
||||
|
||||
@@ -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()<br/>(必须先于业务模块导入)
|
||||
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` 独立捕获异常,保证后续资源仍有机会释放。
|
||||
|
||||
---
|
||||
|
||||
@@ -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,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。
|
||||
|
||||
@@ -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. 结论先行
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
+11
-5
@@ -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",
|
||||
|
||||
+162
-156
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user