mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: unify database readiness lifecycle
This commit is contained in:
+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