mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +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,
|
||||
)
|
||||
|
||||
@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
|
||||
def init_plugin_report(cls) -> None:
|
||||
"""
|
||||
@@ -370,6 +379,15 @@ class MoviePilotServerHelper:
|
||||
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
|
||||
def _handle_list_response(res) -> List[dict]:
|
||||
"""
|
||||
@@ -677,6 +695,15 @@ class MoviePilotServerHelper:
|
||||
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
|
||||
def subscribe_report(cls, subscribes: List[Dict[str, Any]]):
|
||||
"""
|
||||
@@ -953,6 +980,13 @@ class MoviePilotServerHelper:
|
||||
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
|
||||
def sub_share(
|
||||
cls,
|
||||
|
||||
@@ -57,6 +57,7 @@ from app.api.dependencies.plugin import (
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
@@ -879,6 +880,8 @@ async def save_plugin_folders(
|
||||
folders,
|
||||
)
|
||||
return _SchemaResponse(success=True)
|
||||
except DatabaseWorkerOverloadedError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
|
||||
return _SchemaResponse(success=False, message=str(e))
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
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.schemas.types import MediaType
|
||||
@@ -315,7 +315,8 @@ class SystemConfigService:
|
||||
"""异步写入配置,并等待数据库提交或回滚完成。"""
|
||||
if self._async_executor is None:
|
||||
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:
|
||||
"""删除配置。"""
|
||||
|
||||
@@ -18,6 +18,14 @@ DatabaseProbe = Callable[[], Optional[str]]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class DatabaseWorkerClosedError(RuntimeError):
|
||||
"""数据库执行器尚未启动或已经停止。"""
|
||||
|
||||
|
||||
class DatabaseWorkerOverloadedError(RuntimeError):
|
||||
"""数据库执行器的运行与排队容量已经用尽。"""
|
||||
|
||||
|
||||
class AsyncDatabaseExecutor(Protocol):
|
||||
"""让异步业务调用同步短事务而不阻塞事件循环。"""
|
||||
|
||||
|
||||
@@ -24,19 +24,25 @@ class ServerReportService:
|
||||
config_writer: Callable[[Any, Any], Any],
|
||||
installed_plugins_provider: Callable[[], list[str]],
|
||||
subscribes_provider: Callable[[], list[Any]],
|
||||
async_subscribes_provider: Callable[[], Awaitable[list[Any]]] | None = None,
|
||||
plugin_report_sender: Callable[[list[dict]], Any],
|
||||
async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]],
|
||||
subscribe_report_sender: Callable[[list[dict]], Any],
|
||||
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:
|
||||
"""保存本地读取端口和只负责 I/O 的中心服务发送端口。"""
|
||||
self._config_reader = config_reader
|
||||
self._config_writer = config_writer
|
||||
self._installed_plugins_provider = installed_plugins_provider
|
||||
self._subscribes_provider = subscribes_provider
|
||||
self._async_subscribes_provider = async_subscribes_provider
|
||||
self._plugin_report_sender = plugin_report_sender
|
||||
self._async_plugin_report_sender = async_plugin_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
|
||||
|
||||
def init_report(
|
||||
@@ -50,6 +56,22 @@ class ServerReportService:
|
||||
if enabled and not self._config_reader(state_key) and reporter():
|
||||
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]:
|
||||
"""构造中心服务订阅统计载荷并移除本地运行字段。"""
|
||||
if not isinstance(item, dict):
|
||||
@@ -132,3 +154,24 @@ class ServerReportService:
|
||||
return False
|
||||
response = await self._async_plugin_report_sender(payload)
|
||||
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.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.observability import record_metric
|
||||
|
||||
@@ -304,8 +305,8 @@ def connection_budget() -> Dict[str, int]:
|
||||
else:
|
||||
sync_max = settings.DB_SQLITE_POOL_SIZE + settings.DB_SQLITE_MAX_OVERFLOW
|
||||
if settings.DB_POOL_TYPE == "NullPool":
|
||||
# 同步侧也可能被配成 NullPool,此时同样无界,用线程池规模作为可观测的上限估计
|
||||
sync_max = settings.CONF.threadpool
|
||||
# 未池化连接由通用线程池和专属数据库 worker 共同创建,二者都要计入上限估计。
|
||||
sync_max = settings.CONF.threadpool + DATABASE_WORKER_MAX_WORKERS
|
||||
async_max = (settings.DB_ASYNC_POOL_SIZE + settings.DB_ASYNC_MAX_OVERFLOW
|
||||
if _async_pool_enabled() else 0)
|
||||
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 typing import Callable, TypeVar
|
||||
|
||||
from app.application.database import (
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
from app.runtime.observability import record_metric
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class DatabaseWorkerClosedError(RuntimeError):
|
||||
"""数据库执行器尚未启动或已经停止。"""
|
||||
|
||||
|
||||
class DatabaseWorkerOverloadedError(RuntimeError):
|
||||
"""数据库执行器的运行与排队容量已经用尽。"""
|
||||
DATABASE_WORKER_MAX_WORKERS = 4
|
||||
DATABASE_WORKER_CAPACITY = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -47,7 +46,12 @@ class _WorkItem:
|
||||
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:
|
||||
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.health import install_health_routes
|
||||
from app.application.plugin.routes import configure_plugin_routes
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.adapters.web.security.access import (
|
||||
configure_token_codec,
|
||||
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(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
@@ -306,6 +322,10 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
_app.add_exception_handler(
|
||||
DatabaseWorkerOverloadedError,
|
||||
database_worker_overloaded_handler,
|
||||
)
|
||||
_app.add_exception_handler(
|
||||
RequestValidationError,
|
||||
localized_validation_exception_handler,
|
||||
|
||||
@@ -135,7 +135,10 @@ async def initialize_modules_component(app: FastAPI) -> None:
|
||||
except BaseException:
|
||||
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
|
||||
if runtime is not None:
|
||||
app.state.host_runtime = runtime
|
||||
|
||||
@@ -165,9 +165,9 @@ async def stop_database_worker() -> None:
|
||||
"""停止当前进程的数据库短事务 worker。"""
|
||||
global _database_worker
|
||||
worker = _database_worker
|
||||
_database_worker = None
|
||||
if worker is not None:
|
||||
await worker.shutdown()
|
||||
_database_worker = None
|
||||
|
||||
|
||||
async def _initialize_configuration_services(
|
||||
@@ -237,15 +237,20 @@ def configure_runtime_data_providers() -> None:
|
||||
report_service=ServerReportService(
|
||||
config_reader=lambda key: get_configured_system_config().get(key),
|
||||
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(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
subscribes_provider=lambda: SubscribeOper().list(),
|
||||
async_subscribes_provider=lambda: SubscribeOper().async_list(),
|
||||
plugin_report_sender=MoviePilotServerHelper.plugin_install_report,
|
||||
async_plugin_report_sender=(
|
||||
MoviePilotServerHelper.async_plugin_install_report
|
||||
),
|
||||
subscribe_report_sender=MoviePilotServerHelper.subscribe_report,
|
||||
async_subscribe_report_sender=MoviePilotServerHelper.async_subscribe_report,
|
||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||
),
|
||||
sharing_service=ServerSharingService(
|
||||
@@ -542,7 +547,10 @@ async def stop_modules():
|
||||
await run_step("Redis缓存连接", lambda: RedisHelper().close())
|
||||
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
|
||||
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("临时文件", clear_temp)
|
||||
|
||||
@@ -567,7 +575,10 @@ async def init_modules() -> HostRuntime:
|
||||
try:
|
||||
await _initialize_configuration_services(database_worker)
|
||||
except BaseException:
|
||||
await stop_database_worker()
|
||||
try:
|
||||
await stop_database_worker()
|
||||
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||
raise
|
||||
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
|
||||
api_data = ApiDataPorts(
|
||||
@@ -731,8 +742,8 @@ async def init_modules() -> HostRuntime:
|
||||
# 启动事件消费
|
||||
EventManager().start()
|
||||
# 初始化共享服务端状态
|
||||
MoviePilotServerHelper.init_plugin_report()
|
||||
MoviePilotServerHelper.init_subscribe_report()
|
||||
await MoviePilotServerHelper.async_init_plugin_report()
|
||||
await MoviePilotServerHelper.async_init_subscribe_report()
|
||||
MoviePilotServerHelper.get_user_uuid()
|
||||
MoviePilotServerHelper.get_github_user()
|
||||
# 初始化AI智能体
|
||||
|
||||
Reference in New Issue
Block a user