mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(database): bound async configuration writes
This commit is contained in:
Vendored
+34
@@ -359,6 +359,15 @@ class MoviePilotServerHelper:
|
|||||||
reporter=cls.sub_report,
|
reporter=cls.sub_report,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_init_subscribe_report(cls) -> None:
|
||||||
|
"""异步初始化订阅统计标记。"""
|
||||||
|
await cls._report_service().async_init_report(
|
||||||
|
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
|
||||||
|
state_key=SystemConfigKey.SubscribeReport,
|
||||||
|
reporter=cls.async_sub_report,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init_plugin_report(cls) -> None:
|
def init_plugin_report(cls) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -370,6 +379,15 @@ class MoviePilotServerHelper:
|
|||||||
reporter=cls.install_plugin_report,
|
reporter=cls.install_plugin_report,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_init_plugin_report(cls) -> None:
|
||||||
|
"""异步初始化插件统计标记。"""
|
||||||
|
await cls._report_service().async_init_report(
|
||||||
|
enabled=settings.PLUGIN_STATISTIC_SHARE,
|
||||||
|
state_key=SystemConfigKey.PluginInstallReport,
|
||||||
|
reporter=cls.async_install_plugin_report,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_list_response(res) -> List[dict]:
|
def _handle_list_response(res) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -677,6 +695,15 @@ class MoviePilotServerHelper:
|
|||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_subscribe_report(cls, subscribes: List[Dict[str, Any]]):
|
||||||
|
"""异步批量上报存量订阅统计。"""
|
||||||
|
return await cls._async_post_json(
|
||||||
|
cls._server_url(cls._SUBSCRIBE_REPORT_PATH),
|
||||||
|
{"subscribes": subscribes},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def subscribe_report(cls, subscribes: List[Dict[str, Any]]):
|
def subscribe_report(cls, subscribes: List[Dict[str, Any]]):
|
||||||
"""
|
"""
|
||||||
@@ -953,6 +980,13 @@ class MoviePilotServerHelper:
|
|||||||
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
|
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_sub_report(cls) -> bool:
|
||||||
|
"""异步上报存量订阅统计。"""
|
||||||
|
return await cls._report_service().async_report_subscribes(
|
||||||
|
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def sub_share(
|
def sub_share(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from app.api.dependencies.plugin import (
|
|||||||
from app.adapters.external.server import MoviePilotServerHelper
|
from app.adapters.external.server import MoviePilotServerHelper
|
||||||
from app.adapters.external.market import PluginHelper
|
from app.adapters.external.market import PluginHelper
|
||||||
from app.adapters.system.plugin.package import PluginPackageManager
|
from app.adapters.system.plugin.package import PluginPackageManager
|
||||||
|
from app.application.database import DatabaseWorkerOverloadedError
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
@@ -879,6 +880,8 @@ async def save_plugin_folders(
|
|||||||
folders,
|
folders,
|
||||||
)
|
)
|
||||||
return _SchemaResponse(success=True)
|
return _SchemaResponse(success=True)
|
||||||
|
except DatabaseWorkerOverloadedError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
|
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
|
||||||
return _SchemaResponse(success=False, message=str(e))
|
return _SchemaResponse(success=False, message=str(e))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol, cast
|
||||||
|
|
||||||
from app.application.database import AsyncDatabaseExecutor
|
from app.application.database import AsyncDatabaseExecutor
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
@@ -315,7 +315,8 @@ class SystemConfigService:
|
|||||||
"""异步写入配置,并等待数据库提交或回滚完成。"""
|
"""异步写入配置,并等待数据库提交或回滚完成。"""
|
||||||
if self._async_executor is None:
|
if self._async_executor is None:
|
||||||
raise RuntimeError("系统配置异步数据库执行端口尚未配置")
|
raise RuntimeError("系统配置异步数据库执行端口尚未配置")
|
||||||
return await self._async_executor.run(partial(self._writer.set, key, value))
|
result = await self._async_executor.run(partial(self._writer.set, key, value))
|
||||||
|
return cast(bool | None, result)
|
||||||
|
|
||||||
def delete(self, key: Any) -> Any:
|
def delete(self, key: Any) -> Any:
|
||||||
"""删除配置。"""
|
"""删除配置。"""
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ DatabaseProbe = Callable[[], Optional[str]]
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseWorkerClosedError(RuntimeError):
|
||||||
|
"""数据库执行器尚未启动或已经停止。"""
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseWorkerOverloadedError(RuntimeError):
|
||||||
|
"""数据库执行器的运行与排队容量已经用尽。"""
|
||||||
|
|
||||||
|
|
||||||
class AsyncDatabaseExecutor(Protocol):
|
class AsyncDatabaseExecutor(Protocol):
|
||||||
"""让异步业务调用同步短事务而不阻塞事件循环。"""
|
"""让异步业务调用同步短事务而不阻塞事件循环。"""
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,25 @@ class ServerReportService:
|
|||||||
config_writer: Callable[[Any, Any], Any],
|
config_writer: Callable[[Any, Any], Any],
|
||||||
installed_plugins_provider: Callable[[], list[str]],
|
installed_plugins_provider: Callable[[], list[str]],
|
||||||
subscribes_provider: Callable[[], list[Any]],
|
subscribes_provider: Callable[[], list[Any]],
|
||||||
|
async_subscribes_provider: Callable[[], Awaitable[list[Any]]] | None = None,
|
||||||
plugin_report_sender: Callable[[list[dict]], Any],
|
plugin_report_sender: Callable[[list[dict]], Any],
|
||||||
async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]],
|
async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]],
|
||||||
subscribe_report_sender: Callable[[list[dict]], Any],
|
subscribe_report_sender: Callable[[list[dict]], Any],
|
||||||
repo_url_sanitizer: Callable[[Optional[str]], Optional[str]],
|
repo_url_sanitizer: Callable[[Optional[str]], Optional[str]],
|
||||||
|
async_subscribe_report_sender: Callable[[list[dict]], Awaitable[Any]] | None = None,
|
||||||
|
async_config_writer: Callable[[Any, Any], Awaitable[Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""保存本地读取端口和只负责 I/O 的中心服务发送端口。"""
|
"""保存本地读取端口和只负责 I/O 的中心服务发送端口。"""
|
||||||
self._config_reader = config_reader
|
self._config_reader = config_reader
|
||||||
self._config_writer = config_writer
|
self._config_writer = config_writer
|
||||||
self._installed_plugins_provider = installed_plugins_provider
|
self._installed_plugins_provider = installed_plugins_provider
|
||||||
self._subscribes_provider = subscribes_provider
|
self._subscribes_provider = subscribes_provider
|
||||||
|
self._async_subscribes_provider = async_subscribes_provider
|
||||||
self._plugin_report_sender = plugin_report_sender
|
self._plugin_report_sender = plugin_report_sender
|
||||||
self._async_plugin_report_sender = async_plugin_report_sender
|
self._async_plugin_report_sender = async_plugin_report_sender
|
||||||
self._subscribe_report_sender = subscribe_report_sender
|
self._subscribe_report_sender = subscribe_report_sender
|
||||||
|
self._async_subscribe_report_sender = async_subscribe_report_sender
|
||||||
|
self._async_config_writer = async_config_writer
|
||||||
self._repo_url_sanitizer = repo_url_sanitizer
|
self._repo_url_sanitizer = repo_url_sanitizer
|
||||||
|
|
||||||
def init_report(
|
def init_report(
|
||||||
@@ -50,6 +56,22 @@ class ServerReportService:
|
|||||||
if enabled and not self._config_reader(state_key) and reporter():
|
if enabled and not self._config_reader(state_key) and reporter():
|
||||||
self._config_writer(state_key, "1")
|
self._config_writer(state_key, "1")
|
||||||
|
|
||||||
|
async def async_init_report(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
state_key: Any,
|
||||||
|
reporter: Callable[[], Awaitable[bool]],
|
||||||
|
) -> None:
|
||||||
|
"""异步完成首次上报,并通过异步配置端口持久化完成标记。"""
|
||||||
|
if not enabled or self._config_reader(state_key):
|
||||||
|
return
|
||||||
|
if not await reporter():
|
||||||
|
return
|
||||||
|
if self._async_config_writer is None:
|
||||||
|
raise RuntimeError("中心服务上报未配置异步配置写入端口")
|
||||||
|
await self._async_config_writer(state_key, "1")
|
||||||
|
|
||||||
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
|
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
|
||||||
"""构造中心服务订阅统计载荷并移除本地运行字段。"""
|
"""构造中心服务订阅统计载荷并移除本地运行字段。"""
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@@ -132,3 +154,24 @@ class ServerReportService:
|
|||||||
return False
|
return False
|
||||||
response = await self._async_plugin_report_sender(payload)
|
response = await self._async_plugin_report_sender(payload)
|
||||||
return bool(response is not None and response.status_code == 200)
|
return bool(response is not None and response.status_code == 200)
|
||||||
|
|
||||||
|
async def async_report_subscribes(self, *, enabled: bool) -> bool:
|
||||||
|
"""异步上报当前全部有效订阅的公开统计字段。"""
|
||||||
|
if not enabled:
|
||||||
|
return False
|
||||||
|
if self._async_subscribe_report_sender is None:
|
||||||
|
raise RuntimeError("中心服务上报未配置异步订阅发送端口")
|
||||||
|
if self._async_subscribes_provider is None:
|
||||||
|
raise RuntimeError("中心服务未配置异步订阅读取端口")
|
||||||
|
subscribes = await self._async_subscribes_provider()
|
||||||
|
if not subscribes:
|
||||||
|
return True
|
||||||
|
payloads = [
|
||||||
|
payload
|
||||||
|
for subscribe in subscribes
|
||||||
|
if (payload := self.build_subscribe_payload(subscribe.to_dict()))
|
||||||
|
]
|
||||||
|
if not payloads:
|
||||||
|
return True
|
||||||
|
response = await self._async_subscribe_report_sender(payloads)
|
||||||
|
return bool(response is not None and response.status_code == 200)
|
||||||
|
|||||||
+3
-2
@@ -14,6 +14,7 @@ from sqlalchemy.pool import Pool
|
|||||||
|
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
from app.db.diagnostics import _register_database_error_logging
|
from app.db.diagnostics import _register_database_error_logging
|
||||||
|
from app.db.worker import DATABASE_WORKER_MAX_WORKERS
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.runtime.observability import record_metric
|
from app.runtime.observability import record_metric
|
||||||
|
|
||||||
@@ -304,8 +305,8 @@ def connection_budget() -> Dict[str, int]:
|
|||||||
else:
|
else:
|
||||||
sync_max = settings.DB_SQLITE_POOL_SIZE + settings.DB_SQLITE_MAX_OVERFLOW
|
sync_max = settings.DB_SQLITE_POOL_SIZE + settings.DB_SQLITE_MAX_OVERFLOW
|
||||||
if settings.DB_POOL_TYPE == "NullPool":
|
if settings.DB_POOL_TYPE == "NullPool":
|
||||||
# 同步侧也可能被配成 NullPool,此时同样无界,用线程池规模作为可观测的上限估计
|
# 未池化连接由通用线程池和专属数据库 worker 共同创建,二者都要计入上限估计。
|
||||||
sync_max = settings.CONF.threadpool
|
sync_max = settings.CONF.threadpool + DATABASE_WORKER_MAX_WORKERS
|
||||||
async_max = (settings.DB_ASYNC_POOL_SIZE + settings.DB_ASYNC_MAX_OVERFLOW
|
async_max = (settings.DB_ASYNC_POOL_SIZE + settings.DB_ASYNC_MAX_OVERFLOW
|
||||||
if _async_pool_enabled() else 0)
|
if _async_pool_enabled() else 0)
|
||||||
fallback = settings.DB_ASYNC_FALLBACK_LIMIT if _async_pool_enabled() else settings.CONF.scheduler
|
fallback = settings.DB_ASYNC_FALLBACK_LIMIT if _async_pool_enabled() else settings.CONF.scheduler
|
||||||
|
|||||||
+12
-8
@@ -10,18 +10,17 @@ from contextvars import copy_context
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
|
from app.application.database import (
|
||||||
|
DatabaseWorkerClosedError,
|
||||||
|
DatabaseWorkerOverloadedError,
|
||||||
|
)
|
||||||
from app.runtime.observability import record_metric
|
from app.runtime.observability import record_metric
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
DATABASE_WORKER_MAX_WORKERS = 4
|
||||||
class DatabaseWorkerClosedError(RuntimeError):
|
DATABASE_WORKER_CAPACITY = 32
|
||||||
"""数据库执行器尚未启动或已经停止。"""
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseWorkerOverloadedError(RuntimeError):
|
|
||||||
"""数据库执行器的运行与排队容量已经用尽。"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -47,7 +46,12 @@ class _WorkItem:
|
|||||||
class DatabaseWorker:
|
class DatabaseWorker:
|
||||||
"""以有限线程和队列执行不能原生异步化的数据库短事务。"""
|
"""以有限线程和队列执行不能原生异步化的数据库短事务。"""
|
||||||
|
|
||||||
def __init__(self, *, max_workers: int = 4, capacity: int = 32) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_workers: int = DATABASE_WORKER_MAX_WORKERS,
|
||||||
|
capacity: int = DATABASE_WORKER_CAPACITY,
|
||||||
|
) -> None:
|
||||||
"""保存容量配置,线程只在显式启动后创建。"""
|
"""保存容量配置,线程只在显式启动后创建。"""
|
||||||
if max_workers < 1:
|
if max_workers < 1:
|
||||||
raise ValueError("数据库 worker 线程数必须大于 0")
|
raise ValueError("数据库 worker 线程数必须大于 0")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.adapters.observability.otel import build_observation_port
|
|||||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||||
from app.adapters.web.health import install_health_routes
|
from app.adapters.web.health import install_health_routes
|
||||||
from app.application.plugin.routes import configure_plugin_routes
|
from app.application.plugin.routes import configure_plugin_routes
|
||||||
|
from app.application.database import DatabaseWorkerOverloadedError
|
||||||
from app.adapters.web.security.access import (
|
from app.adapters.web.security.access import (
|
||||||
configure_token_codec,
|
configure_token_codec,
|
||||||
verify_apikey,
|
verify_apikey,
|
||||||
@@ -222,6 +223,21 @@ async def localized_http_exception_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def database_worker_overloaded_handler(
|
||||||
|
request: Request,
|
||||||
|
_exc: DatabaseWorkerOverloadedError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""将数据库短事务背压映射为可重试的 503 响应。"""
|
||||||
|
return await localized_http_exception_handler(
|
||||||
|
request,
|
||||||
|
HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="服务当前繁忙,请稍后重试",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def localized_validation_exception_handler(
|
async def localized_validation_exception_handler(
|
||||||
request: Request,
|
request: Request,
|
||||||
exc: RequestValidationError,
|
exc: RequestValidationError,
|
||||||
@@ -306,6 +322,10 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||||
|
_app.add_exception_handler(
|
||||||
|
DatabaseWorkerOverloadedError,
|
||||||
|
database_worker_overloaded_handler,
|
||||||
|
)
|
||||||
_app.add_exception_handler(
|
_app.add_exception_handler(
|
||||||
RequestValidationError,
|
RequestValidationError,
|
||||||
localized_validation_exception_handler,
|
localized_validation_exception_handler,
|
||||||
|
|||||||
@@ -135,7 +135,10 @@ async def initialize_modules_component(app: FastAPI) -> None:
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
from app.startup.modules_initializer import stop_database_worker
|
from app.startup.modules_initializer import stop_database_worker
|
||||||
|
|
||||||
await stop_database_worker()
|
try:
|
||||||
|
await stop_database_worker()
|
||||||
|
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||||
|
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||||
raise
|
raise
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
app.state.host_runtime = runtime
|
app.state.host_runtime = runtime
|
||||||
|
|||||||
@@ -165,9 +165,9 @@ async def stop_database_worker() -> None:
|
|||||||
"""停止当前进程的数据库短事务 worker。"""
|
"""停止当前进程的数据库短事务 worker。"""
|
||||||
global _database_worker
|
global _database_worker
|
||||||
worker = _database_worker
|
worker = _database_worker
|
||||||
_database_worker = None
|
|
||||||
if worker is not None:
|
if worker is not None:
|
||||||
await worker.shutdown()
|
await worker.shutdown()
|
||||||
|
_database_worker = None
|
||||||
|
|
||||||
|
|
||||||
async def _initialize_configuration_services(
|
async def _initialize_configuration_services(
|
||||||
@@ -237,15 +237,20 @@ def configure_runtime_data_providers() -> None:
|
|||||||
report_service=ServerReportService(
|
report_service=ServerReportService(
|
||||||
config_reader=lambda key: get_configured_system_config().get(key),
|
config_reader=lambda key: get_configured_system_config().get(key),
|
||||||
config_writer=lambda key, value: get_configured_system_config().set(key, value),
|
config_writer=lambda key, value: get_configured_system_config().set(key, value),
|
||||||
|
async_config_writer=lambda key, value: get_configured_system_config().async_set(
|
||||||
|
key, value
|
||||||
|
),
|
||||||
installed_plugins_provider=lambda: get_configured_system_config().get(
|
installed_plugins_provider=lambda: get_configured_system_config().get(
|
||||||
SystemConfigKey.UserInstalledPlugins
|
SystemConfigKey.UserInstalledPlugins
|
||||||
) or [],
|
) or [],
|
||||||
subscribes_provider=lambda: SubscribeOper().list(),
|
subscribes_provider=lambda: SubscribeOper().list(),
|
||||||
|
async_subscribes_provider=lambda: SubscribeOper().async_list(),
|
||||||
plugin_report_sender=MoviePilotServerHelper.plugin_install_report,
|
plugin_report_sender=MoviePilotServerHelper.plugin_install_report,
|
||||||
async_plugin_report_sender=(
|
async_plugin_report_sender=(
|
||||||
MoviePilotServerHelper.async_plugin_install_report
|
MoviePilotServerHelper.async_plugin_install_report
|
||||||
),
|
),
|
||||||
subscribe_report_sender=MoviePilotServerHelper.subscribe_report,
|
subscribe_report_sender=MoviePilotServerHelper.subscribe_report,
|
||||||
|
async_subscribe_report_sender=MoviePilotServerHelper.async_subscribe_report,
|
||||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||||
),
|
),
|
||||||
sharing_service=ServerSharingService(
|
sharing_service=ServerSharingService(
|
||||||
@@ -542,7 +547,10 @@ async def stop_modules():
|
|||||||
await run_step("Redis缓存连接", lambda: RedisHelper().close())
|
await run_step("Redis缓存连接", lambda: RedisHelper().close())
|
||||||
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
|
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
|
||||||
await run_step("数据库任务", stop_database_worker)
|
await run_step("数据库任务", stop_database_worker)
|
||||||
await run_step("数据库连接", close_database)
|
if _database_worker is None:
|
||||||
|
await run_step("数据库连接", close_database)
|
||||||
|
else:
|
||||||
|
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
|
||||||
await run_step("前端服务", stop_frontend)
|
await run_step("前端服务", stop_frontend)
|
||||||
await run_step("临时文件", clear_temp)
|
await run_step("临时文件", clear_temp)
|
||||||
|
|
||||||
@@ -567,7 +575,10 @@ async def init_modules() -> HostRuntime:
|
|||||||
try:
|
try:
|
||||||
await _initialize_configuration_services(database_worker)
|
await _initialize_configuration_services(database_worker)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
await stop_database_worker()
|
try:
|
||||||
|
await stop_database_worker()
|
||||||
|
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||||
|
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||||
raise
|
raise
|
||||||
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
|
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
|
||||||
api_data = ApiDataPorts(
|
api_data = ApiDataPorts(
|
||||||
@@ -731,8 +742,8 @@ async def init_modules() -> HostRuntime:
|
|||||||
# 启动事件消费
|
# 启动事件消费
|
||||||
EventManager().start()
|
EventManager().start()
|
||||||
# 初始化共享服务端状态
|
# 初始化共享服务端状态
|
||||||
MoviePilotServerHelper.init_plugin_report()
|
await MoviePilotServerHelper.async_init_plugin_report()
|
||||||
MoviePilotServerHelper.init_subscribe_report()
|
await MoviePilotServerHelper.async_init_subscribe_report()
|
||||||
MoviePilotServerHelper.get_user_uuid()
|
MoviePilotServerHelper.get_user_uuid()
|
||||||
MoviePilotServerHelper.get_github_user()
|
MoviePilotServerHelper.get_github_user()
|
||||||
# 初始化AI智能体
|
# 初始化AI智能体
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ CONFIGURATION_EXCLUDED_ROOTS = (
|
|||||||
APP_ROOT / "plugins",
|
APP_ROOT / "plugins",
|
||||||
APP_ROOT / "sdk",
|
APP_ROOT / "sdk",
|
||||||
APP_ROOT / "runtime" / "compat",
|
APP_ROOT / "runtime" / "compat",
|
||||||
|
APP_ROOT / "testing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +86,37 @@ def parse_source(path: Path) -> ast.Module:
|
|||||||
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_type_checking_test(test: ast.expr) -> bool:
|
||||||
|
"""判断条件是否只在静态类型检查阶段成立。"""
|
||||||
|
return (
|
||||||
|
isinstance(test, ast.Name)
|
||||||
|
and test.id == "TYPE_CHECKING"
|
||||||
|
) or (
|
||||||
|
isinstance(test, ast.Attribute)
|
||||||
|
and isinstance(test.value, ast.Name)
|
||||||
|
and test.value.id == "typing"
|
||||||
|
and test.attr == "TYPE_CHECKING"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_runtime_import_nodes(tree: ast.AST):
|
||||||
|
"""遍历运行期导入,排除 ``if TYPE_CHECKING`` 内的仅类型依赖。"""
|
||||||
|
parents: dict[ast.AST, ast.AST] = {}
|
||||||
|
for parent in ast.walk(tree):
|
||||||
|
for child in ast.iter_child_nodes(parent):
|
||||||
|
parents[child] = parent
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
continue
|
||||||
|
parent = parents.get(node)
|
||||||
|
while parent is not None:
|
||||||
|
if isinstance(parent, ast.If) and _is_type_checking_test(parent.test):
|
||||||
|
break
|
||||||
|
parent = parents.get(parent)
|
||||||
|
else:
|
||||||
|
yield node
|
||||||
|
|
||||||
|
|
||||||
def collect_configuration_debt_baseline() -> dict[str, Any]:
|
def collect_configuration_debt_baseline() -> dict[str, Any]:
|
||||||
"""收集宿主 canonical 代码直接读取 settings 和构造数据库配置适配器的债务。"""
|
"""收集宿主 canonical 代码直接读取 settings 和构造数据库配置适配器的债务。"""
|
||||||
settings_files: list[str] = []
|
settings_files: list[str] = []
|
||||||
@@ -124,6 +156,7 @@ def collect_configuration_debt_baseline() -> dict[str, Any]:
|
|||||||
"app/plugins",
|
"app/plugins",
|
||||||
"app/sdk",
|
"app/sdk",
|
||||||
"app/runtime/compat",
|
"app/runtime/compat",
|
||||||
|
"app/testing",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"settings_imports": {
|
"settings_imports": {
|
||||||
@@ -144,7 +177,7 @@ def iter_import_candidates(
|
|||||||
"""提取模块导入候选,第二项记录 from-import 的具体符号。"""
|
"""提取模块导入候选,第二项记录 from-import 的具体符号。"""
|
||||||
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
|
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
|
||||||
candidates: list[tuple[str, Optional[str]]] = []
|
candidates: list[tuple[str, Optional[str]]] = []
|
||||||
for node in ast.walk(parse_source(path)):
|
for node in iter_runtime_import_nodes(parse_source(path)):
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, ast.Import):
|
||||||
candidates.extend((alias.name, None) for alias in node.names)
|
candidates.extend((alias.name, None) for alias in node.names)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2383,11 +2383,23 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
|
|||||||
from app.startup.database_initializer import prepare_database
|
from app.startup.database_initializer import prepare_database
|
||||||
from app.db.oper.systemconfig import SystemConfigOper
|
from app.db.oper.systemconfig import SystemConfigOper
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
from app.db.session import SessionFactory, async_session_scope
|
||||||
|
from app.db.uow import configure_transaction_runners
|
||||||
|
from app.startup.transaction import TransactionalWriteRunner
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
|
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
transaction_runner = TransactionalWriteRunner(
|
||||||
|
sync_session=SessionFactory,
|
||||||
|
async_session=async_session_scope,
|
||||||
|
)
|
||||||
|
configure_transaction_runners(
|
||||||
|
sync=transaction_runner.sync,
|
||||||
|
async_=transaction_runner.async_,
|
||||||
|
)
|
||||||
|
|
||||||
generated_password = None
|
generated_password = None
|
||||||
|
|
||||||
def prepare_superuser_password() -> None:
|
def prepare_superuser_password() -> None:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"excluded": [
|
"excluded": [
|
||||||
"app/plugins",
|
"app/plugins",
|
||||||
"app/sdk",
|
"app/sdk",
|
||||||
"app/runtime/compat"
|
"app/runtime/compat",
|
||||||
|
"app/testing"
|
||||||
],
|
],
|
||||||
"root": "app"
|
"root": "app"
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-21
@@ -2054,6 +2054,7 @@
|
|||||||
"app.api.endpoints.plugin -> app.application",
|
"app.api.endpoints.plugin -> app.application",
|
||||||
"app.api.endpoints.plugin -> app.application.commands",
|
"app.api.endpoints.plugin -> app.application.commands",
|
||||||
"app.api.endpoints.plugin -> app.application.configuration",
|
"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",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
"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.file",
|
||||||
"app.application.chain.durable_events -> app.schemas.transfer",
|
"app.application.chain.durable_events -> app.schemas.transfer",
|
||||||
"app.application.chain.durable_events -> app.schemas.types",
|
"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",
|
||||||
"app.application.configuration -> app.schemas.types",
|
"app.application.configuration -> app.schemas.types",
|
||||||
"app.application.dashboard -> app.schemas",
|
"app.application.dashboard -> app.schemas",
|
||||||
"app.application.dashboard -> app.schemas.dashboard",
|
"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",
|
||||||
"app.application.directory -> app.adapters.system",
|
"app.application.directory -> app.adapters.system",
|
||||||
"app.application.directory -> app.adapters.system.host",
|
"app.application.directory -> app.adapters.system.host",
|
||||||
@@ -2714,6 +2714,8 @@
|
|||||||
"app.application.security.url -> app.runtime",
|
"app.application.security.url -> app.runtime",
|
||||||
"app.application.security.url -> app.runtime.coalesce",
|
"app.application.security.url -> app.runtime.coalesce",
|
||||||
"app.application.security.url -> app.runtime.log",
|
"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",
|
||||||
"app.application.servarr -> app.schemas.types",
|
"app.application.servarr -> app.schemas.types",
|
||||||
"app.application.server.report -> app.schemas",
|
"app.application.server.report -> app.schemas",
|
||||||
@@ -3454,6 +3456,7 @@
|
|||||||
"app.db.diagnostics -> app.runtime.log",
|
"app.db.diagnostics -> app.runtime.log",
|
||||||
"app.db.engine -> app.db",
|
"app.db.engine -> app.db",
|
||||||
"app.db.engine -> app.db.diagnostics",
|
"app.db.engine -> app.db.diagnostics",
|
||||||
|
"app.db.engine -> app.db.worker",
|
||||||
"app.db.engine -> app.runtime",
|
"app.db.engine -> app.runtime",
|
||||||
"app.db.engine -> app.runtime.config",
|
"app.db.engine -> app.runtime.config",
|
||||||
"app.db.engine -> app.runtime.log",
|
"app.db.engine -> app.runtime.log",
|
||||||
@@ -3562,23 +3565,6 @@
|
|||||||
"app.db.models.workflow -> app.db",
|
"app.db.models.workflow -> app.db",
|
||||||
"app.db.models.workflow -> app.db.base",
|
"app.db.models.workflow -> app.db.base",
|
||||||
"app.db.models.workflow -> app.db.decorators",
|
"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",
|
||||||
"app.db.oper.agentchat -> app.db.base",
|
"app.db.oper.agentchat -> app.db.base",
|
||||||
"app.db.oper.agentchat -> app.db.models",
|
"app.db.oper.agentchat -> app.db.models",
|
||||||
@@ -3682,6 +3668,10 @@
|
|||||||
"app.db.session -> app.runtime.config",
|
"app.db.session -> app.runtime.config",
|
||||||
"app.db.session -> app.runtime.log",
|
"app.db.session -> app.runtime.log",
|
||||||
"app.db.session -> app.runtime.observability",
|
"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",
|
||||||
"app.doctor.checks -> app.adapters.system",
|
"app.doctor.checks -> app.adapters.system",
|
||||||
"app.doctor.checks -> app.adapters.system.backup",
|
"app.doctor.checks -> app.adapters.system.backup",
|
||||||
@@ -3805,6 +3795,7 @@
|
|||||||
"app.factory -> app.api",
|
"app.factory -> app.api",
|
||||||
"app.factory -> app.api.response",
|
"app.factory -> app.api.response",
|
||||||
"app.factory -> app.application",
|
"app.factory -> app.application",
|
||||||
|
"app.factory -> app.application.database",
|
||||||
"app.factory -> app.application.plugin",
|
"app.factory -> app.application.plugin",
|
||||||
"app.factory -> app.application.plugin.routes",
|
"app.factory -> app.application.plugin.routes",
|
||||||
"app.factory -> app.application.security",
|
"app.factory -> app.application.security",
|
||||||
@@ -6137,6 +6128,7 @@
|
|||||||
"app.startup.modules_initializer -> app.db.oper.workflow",
|
"app.startup.modules_initializer -> app.db.oper.workflow",
|
||||||
"app.startup.modules_initializer -> app.db.session",
|
"app.startup.modules_initializer -> app.db.session",
|
||||||
"app.startup.modules_initializer -> app.db.uow",
|
"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",
|
||||||
"app.startup.modules_initializer -> app.runtime.cache",
|
"app.startup.modules_initializer -> app.runtime.cache",
|
||||||
"app.startup.modules_initializer -> app.runtime.config",
|
"app.startup.modules_initializer -> app.runtime.config",
|
||||||
@@ -6259,6 +6251,10 @@
|
|||||||
"app.testing -> app.testing.stub",
|
"app.testing -> app.testing.stub",
|
||||||
"app.testing.bootstrap -> app.application",
|
"app.testing.bootstrap -> app.application",
|
||||||
"app.testing.bootstrap -> app.application.site",
|
"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",
|
||||||
"app.testing.bootstrap -> app.startup.cache_initializer",
|
"app.testing.bootstrap -> app.startup.cache_initializer",
|
||||||
"app.testing.bootstrap -> app.startup.database_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",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 796,
|
"module_count": 797,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -6862,6 +6858,7 @@
|
|||||||
"app.db.oper.workflow",
|
"app.db.oper.workflow",
|
||||||
"app.db.session",
|
"app.db.session",
|
||||||
"app.db.uow",
|
"app.db.uow",
|
||||||
|
"app.db.worker",
|
||||||
"app.doctor",
|
"app.doctor",
|
||||||
"app.doctor.checks",
|
"app.doctor.checks",
|
||||||
"app.doctor.formatters",
|
"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, "user_auth", MagicMock())
|
||||||
monkeypatch.setattr(modules_initializer.EventManager, "start", MagicMock())
|
monkeypatch.setattr(modules_initializer.EventManager, "start", MagicMock())
|
||||||
for name in (
|
for name in (
|
||||||
"init_plugin_report",
|
"async_init_plugin_report",
|
||||||
"init_subscribe_report",
|
"async_init_subscribe_report",
|
||||||
"get_user_uuid",
|
"get_user_uuid",
|
||||||
"get_github_user",
|
"get_github_user",
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
modules_initializer.MoviePilotServerHelper,
|
modules_initializer.MoviePilotServerHelper,
|
||||||
name,
|
name,
|
||||||
MagicMock(),
|
AsyncMock() if name.startswith("async_") else MagicMock(),
|
||||||
)
|
)
|
||||||
start_frontend = MagicMock()
|
start_frontend = MagicMock()
|
||||||
check_auth = MagicMock()
|
check_auth = MagicMock()
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ from app.api.response import (
|
|||||||
ResponseAPIRouter,
|
ResponseAPIRouter,
|
||||||
)
|
)
|
||||||
from app.factory import (
|
from app.factory import (
|
||||||
|
database_worker_overloaded_handler,
|
||||||
localized_http_exception_handler,
|
localized_http_exception_handler,
|
||||||
localized_unhandled_exception_handler,
|
localized_unhandled_exception_handler,
|
||||||
localized_validation_exception_handler,
|
localized_validation_exception_handler,
|
||||||
)
|
)
|
||||||
|
from app.application.database import DatabaseWorkerOverloadedError
|
||||||
from app.runtime.localization import LocaleHelper
|
from app.runtime.localization import LocaleHelper
|
||||||
from app.schemas.common import JsonData
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.response import Response
|
from app.schemas.response import Response
|
||||||
@@ -59,6 +61,10 @@ def api_app() -> FastAPI:
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.router.route_class = ResponseAPIRoute
|
app.router.route_class = ResponseAPIRoute
|
||||||
app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||||
|
app.add_exception_handler(
|
||||||
|
DatabaseWorkerOverloadedError,
|
||||||
|
database_worker_overloaded_handler,
|
||||||
|
)
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
app.add_exception_handler(
|
app.add_exception_handler(
|
||||||
@@ -112,6 +118,11 @@ def api_app() -> FastAPI:
|
|||||||
"""抛出需要隐藏内部细节的未捕获异常。"""
|
"""抛出需要隐藏内部细节的未捕获异常。"""
|
||||||
raise RuntimeError("private failure detail")
|
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)
|
@app.get("/native", response_model=None)
|
||||||
async def get_native_response() -> dict[str, bool]:
|
async def get_native_response() -> dict[str, bool]:
|
||||||
"""返回显式旁路的原生 JSON 协议。"""
|
"""返回显式旁路的原生 JSON 协议。"""
|
||||||
@@ -190,6 +201,22 @@ async def test_accept_language_localizes_success_and_http_error(api_app: FastAPI
|
|||||||
assert zh_error_response.json()["message"] == "用户名或密码错误"
|
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 def test_validation_error_uses_unified_model(api_app: FastAPI):
|
||||||
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
||||||
async with make_client(api_app) as client:
|
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/plugins",
|
||||||
"app/sdk",
|
"app/sdk",
|
||||||
"app/runtime/compat",
|
"app/runtime/compat",
|
||||||
|
"app/testing",
|
||||||
]
|
]
|
||||||
assert baseline["settings_imports"]["count"] == len(
|
assert baseline["settings_imports"]["count"] == len(
|
||||||
baseline["settings_imports"]["files"]
|
baseline["settings_imports"]["files"]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import ast
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scripts.architecture.baseline import iter_runtime_import_nodes
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parents[1]
|
PROJECT_ROOT = Path(__file__).parents[1]
|
||||||
APP_ROOT = PROJECT_ROOT / "app"
|
APP_ROOT = PROJECT_ROOT / "app"
|
||||||
LEGACY_ROOTS = ("app.core", "app.helper", "app.utils")
|
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))
|
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]
|
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
|
||||||
dependencies: set[str] = set()
|
dependencies: set[str] = set()
|
||||||
for node in ast.walk(tree):
|
for node in iter_runtime_import_nodes(tree):
|
||||||
candidates: list[str] = []
|
candidates: list[str] = []
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, ast.Import):
|
||||||
candidates.extend(alias.name for alias in node.names)
|
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, "user_auth", lambda: None)
|
||||||
monkeypatch.setattr(modules_initializer, "ModuleManager", lambda: None)
|
monkeypatch.setattr(modules_initializer, "ModuleManager", lambda: None)
|
||||||
monkeypatch.setattr(modules_initializer.EventManager, "start", lambda self: 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, "async_init_plugin_report", AsyncMock())
|
||||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "init_subscribe_report", lambda: None)
|
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_user_uuid", lambda: None)
|
||||||
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "get_github_user", lambda: None)
|
monkeypatch.setattr(modules_initializer.MoviePilotServerHelper, "get_github_user", lambda: None)
|
||||||
init_agent = AsyncMock()
|
init_agent = AsyncMock()
|
||||||
|
|||||||
@@ -102,3 +102,39 @@ async def test_modules_startup_failure_stops_database_worker(monkeypatch) -> Non
|
|||||||
await initialize_modules_component(object())
|
await initialize_modules_component(object())
|
||||||
|
|
||||||
stop_worker.assert_awaited_once_with()
|
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 database as startup_database
|
||||||
from app.startup import lifecycle
|
from app.startup import lifecycle
|
||||||
from app.runtime.health import get_application_health
|
from app.runtime.health import get_application_health
|
||||||
|
from app.db.models.systemconfig import SystemConfig
|
||||||
|
|
||||||
|
|
||||||
LOCAL_SETUP_PATH = (
|
LOCAL_SETUP_PATH = (
|
||||||
@@ -782,3 +783,32 @@ def test_local_setup_returns_failure_when_database_migration_fails(
|
|||||||
|
|
||||||
assert module.main() == 1
|
assert module.main() == 1
|
||||||
assert "migration failed" in capsys.readouterr().err
|
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
|
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 路径)
|
# 额度校验(PostgreSQL 路径)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ class MoviePilotServerHelperTests(unittest.TestCase):
|
|||||||
config_writer=Mock(),
|
config_writer=Mock(),
|
||||||
installed_plugins_provider=Mock(return_value=[]),
|
installed_plugins_provider=Mock(return_value=[]),
|
||||||
subscribes_provider=Mock(return_value=[]),
|
subscribes_provider=Mock(return_value=[]),
|
||||||
|
async_subscribes_provider=AsyncMock(return_value=[]),
|
||||||
plugin_report_sender=Mock(),
|
plugin_report_sender=Mock(),
|
||||||
async_plugin_report_sender=AsyncMock(),
|
async_plugin_report_sender=AsyncMock(),
|
||||||
subscribe_report_sender=Mock(),
|
subscribe_report_sender=Mock(),
|
||||||
|
async_subscribe_report_sender=AsyncMock(),
|
||||||
|
async_config_writer=AsyncMock(),
|
||||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||||
),
|
),
|
||||||
sharing_service=ServerSharingService(
|
sharing_service=ServerSharingService(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.application.server.report import ServerReportService
|
from app.application.server.report import ServerReportService
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ def _service(**overrides) -> ServerReportService:
|
|||||||
"config_writer": Mock(),
|
"config_writer": Mock(),
|
||||||
"installed_plugins_provider": Mock(return_value=[]),
|
"installed_plugins_provider": Mock(return_value=[]),
|
||||||
"subscribes_provider": Mock(return_value=[]),
|
"subscribes_provider": Mock(return_value=[]),
|
||||||
|
"async_subscribes_provider": AsyncMock(return_value=[]),
|
||||||
"plugin_report_sender": Mock(
|
"plugin_report_sender": Mock(
|
||||||
return_value=SimpleNamespace(status_code=200)
|
return_value=SimpleNamespace(status_code=200)
|
||||||
),
|
),
|
||||||
@@ -18,6 +21,8 @@ def _service(**overrides) -> ServerReportService:
|
|||||||
"subscribe_report_sender": Mock(
|
"subscribe_report_sender": Mock(
|
||||||
return_value=SimpleNamespace(status_code=200)
|
return_value=SimpleNamespace(status_code=200)
|
||||||
),
|
),
|
||||||
|
"async_subscribe_report_sender": AsyncMock(),
|
||||||
|
"async_config_writer": AsyncMock(),
|
||||||
"repo_url_sanitizer": lambda value: value,
|
"repo_url_sanitizer": lambda value: value,
|
||||||
}
|
}
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
@@ -62,6 +67,44 @@ def test_initial_report_marker_is_written_only_after_success():
|
|||||||
writer.assert_not_called()
|
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():
|
def test_plugin_report_sanitizes_explicit_sources_before_transport():
|
||||||
"""插件统计载荷在进入传输适配器前完成来源脱敏。"""
|
"""插件统计载荷在进入传输适配器前完成来源脱敏。"""
|
||||||
sender = Mock(return_value=SimpleNamespace(status_code=200))
|
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",
|
username="async-user",
|
||||||
key="theme",
|
key="theme",
|
||||||
).value == "dark"
|
).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