mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat(database): bound async configuration writes
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"excluded": [
|
||||
"app/plugins",
|
||||
"app/sdk",
|
||||
"app/runtime/compat"
|
||||
"app/runtime/compat",
|
||||
"app/testing"
|
||||
],
|
||||
"root": "app"
|
||||
},
|
||||
|
||||
+18
-21
@@ -2054,6 +2054,7 @@
|
||||
"app.api.endpoints.plugin -> app.application",
|
||||
"app.api.endpoints.plugin -> app.application.commands",
|
||||
"app.api.endpoints.plugin -> app.application.configuration",
|
||||
"app.api.endpoints.plugin -> app.application.database",
|
||||
"app.api.endpoints.plugin -> app.application.plugin",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
||||
@@ -2468,13 +2469,12 @@
|
||||
"app.application.chain.durable_events -> app.schemas.file",
|
||||
"app.application.chain.durable_events -> app.schemas.transfer",
|
||||
"app.application.chain.durable_events -> app.schemas.types",
|
||||
"app.application.configuration -> app.application",
|
||||
"app.application.configuration -> app.application.database",
|
||||
"app.application.configuration -> app.schemas",
|
||||
"app.application.configuration -> app.schemas.types",
|
||||
"app.application.dashboard -> app.schemas",
|
||||
"app.application.dashboard -> app.schemas.dashboard",
|
||||
"app.application.database -> app.application",
|
||||
"app.application.database -> app.application.backup",
|
||||
"app.application.database -> app.application.maintenance",
|
||||
"app.application.directory -> app.adapters",
|
||||
"app.application.directory -> app.adapters.system",
|
||||
"app.application.directory -> app.adapters.system.host",
|
||||
@@ -2714,6 +2714,8 @@
|
||||
"app.application.security.url -> app.runtime",
|
||||
"app.application.security.url -> app.runtime.coalesce",
|
||||
"app.application.security.url -> app.runtime.log",
|
||||
"app.application.security.userconfig -> app.application",
|
||||
"app.application.security.userconfig -> app.application.database",
|
||||
"app.application.servarr -> app.schemas",
|
||||
"app.application.servarr -> app.schemas.types",
|
||||
"app.application.server.report -> app.schemas",
|
||||
@@ -3454,6 +3456,7 @@
|
||||
"app.db.diagnostics -> app.runtime.log",
|
||||
"app.db.engine -> app.db",
|
||||
"app.db.engine -> app.db.diagnostics",
|
||||
"app.db.engine -> app.db.worker",
|
||||
"app.db.engine -> app.runtime",
|
||||
"app.db.engine -> app.runtime.config",
|
||||
"app.db.engine -> app.runtime.log",
|
||||
@@ -3562,23 +3565,6 @@
|
||||
"app.db.models.workflow -> app.db",
|
||||
"app.db.models.workflow -> app.db.base",
|
||||
"app.db.models.workflow -> app.db.decorators",
|
||||
"app.db.oper -> app.db",
|
||||
"app.db.oper -> app.db.oper.agentchat",
|
||||
"app.db.oper -> app.db.oper.agenttask",
|
||||
"app.db.oper -> app.db.oper.downloadfailure",
|
||||
"app.db.oper -> app.db.oper.downloadhistory",
|
||||
"app.db.oper -> app.db.oper.mediaserver",
|
||||
"app.db.oper -> app.db.oper.message",
|
||||
"app.db.oper -> app.db.oper.plugindata",
|
||||
"app.db.oper -> app.db.oper.site",
|
||||
"app.db.oper -> app.db.oper.subscribe",
|
||||
"app.db.oper -> app.db.oper.subscribehistory",
|
||||
"app.db.oper -> app.db.oper.systemconfig",
|
||||
"app.db.oper -> app.db.oper.transferhistory",
|
||||
"app.db.oper -> app.db.oper.transferpending",
|
||||
"app.db.oper -> app.db.oper.user",
|
||||
"app.db.oper -> app.db.oper.userconfig",
|
||||
"app.db.oper -> app.db.oper.workflow",
|
||||
"app.db.oper.agentchat -> app.db",
|
||||
"app.db.oper.agentchat -> app.db.base",
|
||||
"app.db.oper.agentchat -> app.db.models",
|
||||
@@ -3682,6 +3668,10 @@
|
||||
"app.db.session -> app.runtime.config",
|
||||
"app.db.session -> app.runtime.log",
|
||||
"app.db.session -> app.runtime.observability",
|
||||
"app.db.worker -> app.application",
|
||||
"app.db.worker -> app.application.database",
|
||||
"app.db.worker -> app.runtime",
|
||||
"app.db.worker -> app.runtime.observability",
|
||||
"app.doctor.checks -> app.adapters",
|
||||
"app.doctor.checks -> app.adapters.system",
|
||||
"app.doctor.checks -> app.adapters.system.backup",
|
||||
@@ -3805,6 +3795,7 @@
|
||||
"app.factory -> app.api",
|
||||
"app.factory -> app.api.response",
|
||||
"app.factory -> app.application",
|
||||
"app.factory -> app.application.database",
|
||||
"app.factory -> app.application.plugin",
|
||||
"app.factory -> app.application.plugin.routes",
|
||||
"app.factory -> app.application.security",
|
||||
@@ -6137,6 +6128,7 @@
|
||||
"app.startup.modules_initializer -> app.db.oper.workflow",
|
||||
"app.startup.modules_initializer -> app.db.session",
|
||||
"app.startup.modules_initializer -> app.db.uow",
|
||||
"app.startup.modules_initializer -> app.db.worker",
|
||||
"app.startup.modules_initializer -> app.runtime",
|
||||
"app.startup.modules_initializer -> app.runtime.cache",
|
||||
"app.startup.modules_initializer -> app.runtime.config",
|
||||
@@ -6259,6 +6251,10 @@
|
||||
"app.testing -> app.testing.stub",
|
||||
"app.testing.bootstrap -> app.application",
|
||||
"app.testing.bootstrap -> app.application.site",
|
||||
"app.testing.bootstrap -> app.db",
|
||||
"app.testing.bootstrap -> app.db.oper",
|
||||
"app.testing.bootstrap -> app.db.oper.systemconfig",
|
||||
"app.testing.bootstrap -> app.db.oper.userconfig",
|
||||
"app.testing.bootstrap -> app.startup",
|
||||
"app.testing.bootstrap -> app.startup.cache_initializer",
|
||||
"app.testing.bootstrap -> app.startup.database_initializer",
|
||||
@@ -6438,7 +6434,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 796,
|
||||
"module_count": 797,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6862,6 +6858,7 @@
|
||||
"app.db.oper.workflow",
|
||||
"app.db.session",
|
||||
"app.db.uow",
|
||||
"app.db.worker",
|
||||
"app.doctor",
|
||||
"app.doctor.checks",
|
||||
"app.doctor.formatters",
|
||||
|
||||
@@ -123,15 +123,15 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
|
||||
monkeypatch.setattr(modules_initializer, "user_auth", MagicMock())
|
||||
monkeypatch.setattr(modules_initializer.EventManager, "start", MagicMock())
|
||||
for name in (
|
||||
"init_plugin_report",
|
||||
"init_subscribe_report",
|
||||
"async_init_plugin_report",
|
||||
"async_init_subscribe_report",
|
||||
"get_user_uuid",
|
||||
"get_github_user",
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
modules_initializer.MoviePilotServerHelper,
|
||||
name,
|
||||
MagicMock(),
|
||||
AsyncMock() if name.startswith("async_") else MagicMock(),
|
||||
)
|
||||
start_frontend = MagicMock()
|
||||
check_auth = MagicMock()
|
||||
|
||||
@@ -17,10 +17,12 @@ from app.api.response import (
|
||||
ResponseAPIRouter,
|
||||
)
|
||||
from app.factory import (
|
||||
database_worker_overloaded_handler,
|
||||
localized_http_exception_handler,
|
||||
localized_unhandled_exception_handler,
|
||||
localized_validation_exception_handler,
|
||||
)
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.runtime.localization import LocaleHelper
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.response import Response
|
||||
@@ -59,6 +61,10 @@ def api_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.router.route_class = ResponseAPIRoute
|
||||
app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
app.add_exception_handler(
|
||||
DatabaseWorkerOverloadedError,
|
||||
database_worker_overloaded_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
app.add_exception_handler(
|
||||
@@ -112,6 +118,11 @@ def api_app() -> FastAPI:
|
||||
"""抛出需要隐藏内部细节的未捕获异常。"""
|
||||
raise RuntimeError("private failure detail")
|
||||
|
||||
@app.get("/database-busy")
|
||||
async def get_database_busy() -> None:
|
||||
"""模拟数据库短事务容量耗尽。"""
|
||||
raise DatabaseWorkerOverloadedError("worker full")
|
||||
|
||||
@app.get("/native", response_model=None)
|
||||
async def get_native_response() -> dict[str, bool]:
|
||||
"""返回显式旁路的原生 JSON 协议。"""
|
||||
@@ -190,6 +201,22 @@ async def test_accept_language_localizes_success_and_http_error(api_app: FastAPI
|
||||
assert zh_error_response.json()["message"] == "用户名或密码错误"
|
||||
|
||||
|
||||
async def test_database_worker_overload_is_retryable_service_unavailable(
|
||||
api_app: FastAPI,
|
||||
):
|
||||
"""数据库 worker 背压应返回 503,而不是伪装成未知错误。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/database-busy")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"success": False,
|
||||
"message": "服务当前繁忙,请稍后重试",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_error_uses_unified_model(api_app: FastAPI):
|
||||
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
|
||||
@@ -180,6 +180,7 @@ def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None:
|
||||
"app/plugins",
|
||||
"app/sdk",
|
||||
"app/runtime/compat",
|
||||
"app/testing",
|
||||
]
|
||||
assert baseline["settings_imports"]["count"] == len(
|
||||
baseline["settings_imports"]["files"]
|
||||
|
||||
@@ -2,6 +2,8 @@ import ast
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.architecture.baseline import iter_runtime_import_nodes
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
APP_ROOT = PROJECT_ROOT / "app"
|
||||
LEGACY_ROOTS = ("app.core", "app.helper", "app.utils")
|
||||
@@ -156,7 +158,7 @@ def _resolve_imports(
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
|
||||
dependencies: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
for node in iter_runtime_import_nodes(tree):
|
||||
candidates: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
candidates.extend(alias.name for alias in node.names)
|
||||
|
||||
@@ -155,8 +155,8 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
|
||||
monkeypatch.setattr(modules_initializer, "user_auth", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "ModuleManager", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer.EventManager, "start", lambda self: None)
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "init_plugin_report", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "init_subscribe_report", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "async_init_plugin_report", AsyncMock())
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "async_init_subscribe_report", AsyncMock())
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "get_user_uuid", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "get_github_user", lambda: None)
|
||||
init_agent = AsyncMock()
|
||||
|
||||
@@ -102,3 +102,39 @@ async def test_modules_startup_failure_stops_database_worker(monkeypatch) -> Non
|
||||
await initialize_modules_component(object())
|
||||
|
||||
stop_worker.assert_awaited_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modules_startup_failure_preserves_original_error_when_cleanup_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""数据库任务清理失败时仍向上层保留原始启动异常。"""
|
||||
monkeypatch.setattr(
|
||||
"app.startup.lifecycle.init_modules",
|
||||
AsyncMock(side_effect=RuntimeError("startup failed")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"stop_database_worker",
|
||||
AsyncMock(side_effect=RuntimeError("cleanup failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="startup failed"):
|
||||
await initialize_modules_component(object())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_worker_owner_is_retained_when_shutdown_fails(monkeypatch) -> None:
|
||||
"""数据库 worker 关闭失败时保留 owner,允许后续重试或诊断。"""
|
||||
|
||||
class _FailingWorker:
|
||||
async def shutdown(self):
|
||||
raise RuntimeError("shutdown failed")
|
||||
|
||||
worker = _FailingWorker()
|
||||
monkeypatch.setattr(modules_initializer, "_database_worker", worker)
|
||||
|
||||
with pytest.raises(RuntimeError, match="shutdown failed"):
|
||||
await modules_initializer.stop_database_worker()
|
||||
|
||||
assert modules_initializer._database_worker is worker
|
||||
|
||||
@@ -31,6 +31,7 @@ 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
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
|
||||
|
||||
LOCAL_SETUP_PATH = (
|
||||
@@ -782,3 +783,32 @@ def test_local_setup_returns_failure_when_database_migration_fails(
|
||||
|
||||
assert module.main() == 1
|
||||
assert "migration failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_local_setup_apply_config_registers_offline_transaction_runner(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
db,
|
||||
) -> None:
|
||||
"""离线 apply-config 写入配置前必须装配同步事务执行器。"""
|
||||
db.watermark(SystemConfig)
|
||||
module = _load_local_setup_module()
|
||||
monkeypatch.setattr(db_init, "prepare_database", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(module, "_ensure_superuser_account_inner", lambda: None)
|
||||
payload = {
|
||||
"directories": [{
|
||||
"name": "offline-config",
|
||||
"download_path": str(tmp_path / "downloads"),
|
||||
"library_path": str(tmp_path / "library"),
|
||||
"priority": 0,
|
||||
}],
|
||||
}
|
||||
|
||||
module._apply_local_system_config_inner(payload)
|
||||
|
||||
persisted = SystemConfig.get_by_key(
|
||||
db.session,
|
||||
"Directories",
|
||||
)
|
||||
assert persisted is not None
|
||||
assert persisted.value[0]["name"] == "offline-config"
|
||||
|
||||
@@ -103,6 +103,17 @@ def test_budget_uses_sqlite_pool_for_sqlite(monkeypatch):
|
||||
assert engine_module.connection_budget()["sync"] == 7
|
||||
|
||||
|
||||
def test_budget_counts_database_worker_with_sync_nullpool(monkeypatch):
|
||||
"""同步 NullPool 需要同时计入通用线程池和专属数据库 worker。"""
|
||||
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
|
||||
monkeypatch.setattr(settings, "DB_POOL_TYPE", "NullPool", raising=False)
|
||||
threadpool_size = settings.CONF.threadpool
|
||||
|
||||
budget = engine_module.connection_budget()
|
||||
|
||||
assert budget["sync"] == threadpool_size + 4
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 额度校验(PostgreSQL 路径)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -29,9 +29,12 @@ class MoviePilotServerHelperTests(unittest.TestCase):
|
||||
config_writer=Mock(),
|
||||
installed_plugins_provider=Mock(return_value=[]),
|
||||
subscribes_provider=Mock(return_value=[]),
|
||||
async_subscribes_provider=AsyncMock(return_value=[]),
|
||||
plugin_report_sender=Mock(),
|
||||
async_plugin_report_sender=AsyncMock(),
|
||||
subscribe_report_sender=Mock(),
|
||||
async_subscribe_report_sender=AsyncMock(),
|
||||
async_config_writer=AsyncMock(),
|
||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||
),
|
||||
sharing_service=ServerSharingService(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.server.report import ServerReportService
|
||||
|
||||
@@ -11,6 +13,7 @@ def _service(**overrides) -> ServerReportService:
|
||||
"config_writer": Mock(),
|
||||
"installed_plugins_provider": Mock(return_value=[]),
|
||||
"subscribes_provider": Mock(return_value=[]),
|
||||
"async_subscribes_provider": AsyncMock(return_value=[]),
|
||||
"plugin_report_sender": Mock(
|
||||
return_value=SimpleNamespace(status_code=200)
|
||||
),
|
||||
@@ -18,6 +21,8 @@ def _service(**overrides) -> ServerReportService:
|
||||
"subscribe_report_sender": Mock(
|
||||
return_value=SimpleNamespace(status_code=200)
|
||||
),
|
||||
"async_subscribe_report_sender": AsyncMock(),
|
||||
"async_config_writer": AsyncMock(),
|
||||
"repo_url_sanitizer": lambda value: value,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
@@ -62,6 +67,44 @@ def test_initial_report_marker_is_written_only_after_success():
|
||||
writer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_initial_report_marker_uses_async_writer_after_success():
|
||||
"""异步首次上报成功后只通过异步配置端口写完成标记。"""
|
||||
sync_writer = Mock()
|
||||
async_writer = AsyncMock()
|
||||
reporter = AsyncMock(return_value=True)
|
||||
service = _service(
|
||||
config_writer=sync_writer,
|
||||
async_config_writer=async_writer,
|
||||
)
|
||||
|
||||
await service.async_init_report(
|
||||
enabled=True,
|
||||
state_key="report",
|
||||
reporter=reporter,
|
||||
)
|
||||
|
||||
reporter.assert_awaited_once_with()
|
||||
async_writer.assert_awaited_once_with("report", "1")
|
||||
sync_writer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subscribe_report_uses_async_reader():
|
||||
"""异步订阅上报通过异步读取端口获取数据,不在事件循环内查同步库。"""
|
||||
sync_reader = Mock(side_effect=AssertionError("不应调用同步订阅读取"))
|
||||
async_reader = AsyncMock(return_value=[])
|
||||
service = _service(
|
||||
subscribes_provider=sync_reader,
|
||||
async_subscribes_provider=async_reader,
|
||||
)
|
||||
|
||||
assert await service.async_report_subscribes(enabled=True) is True
|
||||
|
||||
sync_reader.assert_not_called()
|
||||
async_reader.assert_awaited_once_with()
|
||||
|
||||
|
||||
def test_plugin_report_sanitizes_explicit_sources_before_transport():
|
||||
"""插件统计载荷在进入传输适配器前完成来源脱敏。"""
|
||||
sender = Mock(return_value=SimpleNamespace(status_code=200))
|
||||
|
||||
@@ -91,3 +91,39 @@ async def test_async_write_uses_same_repository_rule() -> None:
|
||||
username="async-user",
|
||||
key="theme",
|
||||
).value == "dark"
|
||||
|
||||
|
||||
def test_existing_falsey_value_is_removed_from_db_but_kept_until_reload(db) -> None:
|
||||
"""已有用户配置写入假值时删除记录,当前快照仍保留该假值直到重载。"""
|
||||
db.watermark(UserConfig)
|
||||
oper = _fresh_oper()
|
||||
|
||||
oper.set("falsey-user", "enabled", True)
|
||||
oper.set("falsey-user", "enabled", False)
|
||||
|
||||
assert UserConfig.get_by_key(
|
||||
oper._db,
|
||||
username="falsey-user",
|
||||
key="enabled",
|
||||
) is None
|
||||
assert oper.get("falsey-user", "enabled") is False
|
||||
|
||||
oper.load_snapshot()
|
||||
assert oper.get("falsey-user", "enabled") is None
|
||||
|
||||
|
||||
def test_falsey_value_without_existing_row_is_persisted(db) -> None:
|
||||
"""不存在的用户配置写入假值时保留记录,兼容历史写入规则。"""
|
||||
db.watermark(UserConfig)
|
||||
oper = _fresh_oper()
|
||||
|
||||
oper.set("new-falsey-user", "enabled", False)
|
||||
|
||||
persisted = UserConfig.get_by_key(
|
||||
oper._db,
|
||||
username="new-falsey-user",
|
||||
key="enabled",
|
||||
)
|
||||
assert persisted is not None
|
||||
assert persisted.value is False
|
||||
assert oper.get("new-falsey-user", "enabled") is False
|
||||
|
||||
Reference in New Issue
Block a user