mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
811 lines
30 KiB
Python
811 lines
30 KiB
Python
"""插件载荷安装、数据库提交和运行态切换的统一应用用例。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from typing import Any, ContextManager, Protocol, TypeVar
|
||
|
||
from app.application.plugin.admission import PluginInstallAdmission
|
||
from app.application.plugin.identity import PluginIdentity, PluginPayloadSourceType
|
||
from app.application.plugin.source import PluginLocalCandidate
|
||
from app.application.plugin.transaction import (
|
||
PluginInstallationConflictError,
|
||
PluginInstallationPhase,
|
||
PluginInstallationRecord,
|
||
PluginPersistenceService,
|
||
)
|
||
from app.runtime.execution import await_task_to_terminal
|
||
from app.runtime.log import logger
|
||
from app.schemas.exception import (
|
||
PersistenceUnavailableError,
|
||
PluginMutationRejectedError,
|
||
)
|
||
|
||
InstalledPluginsReader = Callable[[], list[str]]
|
||
PluginIdsProvider = Callable[[], list[str]]
|
||
InstallReporter = Callable[[str, str | None], Awaitable[object]]
|
||
PluginReloader = Callable[[str], Awaitable[object]]
|
||
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
|
||
PluginMutationAdmission = Callable[[str], ContextManager[None]]
|
||
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
|
||
T = TypeVar("T")
|
||
|
||
|
||
class PluginPackageCheckpoint(Protocol):
|
||
"""安装 journal 构造和崩溃恢复所需的最小文件快照事实。"""
|
||
|
||
plugin_existed: bool
|
||
persistent_backup_existed: bool
|
||
|
||
|
||
class PluginPackageTransactionPort(Protocol):
|
||
"""插件安装用例可见的唯一文件与持久备份事务端口。"""
|
||
|
||
async def async_checkpoint(
|
||
self,
|
||
plugin_id: str,
|
||
transaction_id: str | None = None,
|
||
) -> PluginPackageCheckpoint:
|
||
"""创建运行目录恢复快照。"""
|
||
|
||
async def async_install(
|
||
self,
|
||
plugin_id: str,
|
||
repo_url: str,
|
||
package_version: str | None = None,
|
||
release_version: str | None = None,
|
||
force_install: bool = False,
|
||
) -> tuple[bool, str]:
|
||
"""执行已经通过来源准入的原始包安装。"""
|
||
|
||
async def async_restore(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||
"""恢复数据库提交前的运行目录和持久备份。"""
|
||
|
||
async def async_cleanup(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||
"""清理已不再被 journal 引用的恢复材料。"""
|
||
|
||
async def async_stage_persistent_backup(
|
||
self,
|
||
checkpoint: PluginPackageCheckpoint,
|
||
) -> None:
|
||
"""准备新的容器持久恢复备份。"""
|
||
|
||
async def async_activate_persistent_backup(
|
||
self,
|
||
checkpoint: PluginPackageCheckpoint,
|
||
) -> None:
|
||
"""激活新备份并保留旧备份用于提交前补偿。"""
|
||
|
||
async def async_finalize_persistent_backup(
|
||
self,
|
||
checkpoint: PluginPackageCheckpoint,
|
||
) -> None:
|
||
"""数据库提交后删除旧持久备份。"""
|
||
|
||
async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||
"""清理已提交事务的运行目录快照。"""
|
||
|
||
async def async_payload_receipt(self, plugin_id: str) -> str:
|
||
"""读取当前运行目录的稳定载荷收据。"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginInstallRollback:
|
||
"""描述提交前失败中各类可补偿副作用的恢复结果。"""
|
||
|
||
file_attempted: bool = False
|
||
file_restored: bool = False
|
||
runtime_attempted: bool = False
|
||
runtime_restored: bool = False
|
||
registrations_attempted: bool = False
|
||
registrations_restored: bool = False
|
||
journal_deleted: bool = False
|
||
errors: tuple[str, ...] = ()
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginInstallResult:
|
||
"""描述插件安装结果、失败阶段和可观察补偿状态。"""
|
||
|
||
success: bool
|
||
message: str = ""
|
||
refreshed_only: bool = False
|
||
package_installed: bool = False
|
||
installed_list_persisted: bool = False
|
||
runtime_reloaded: bool = False
|
||
registrations_refreshed: bool = False
|
||
reported: bool = False
|
||
report_error: str = ""
|
||
failure_stage: str | None = None
|
||
checkpoint_cleanup_error: str = ""
|
||
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _InstallState:
|
||
"""记录取消补偿和数据库提交边界所需的事务状态。"""
|
||
|
||
transaction_id: str
|
||
checkpoint: Any = None
|
||
journal_created: bool = False
|
||
journal_unknown: bool = False
|
||
target_identity: PluginIdentity | None = None
|
||
stage: str = "package_checkpoint"
|
||
package_installed: bool = False
|
||
runtime_touched: bool = False
|
||
registrations_touched: bool = False
|
||
committed: bool = False
|
||
commit_unknown: bool = False
|
||
|
||
|
||
class PluginInstallCommand:
|
||
"""以一个 Gateway 后端协调来源、文件、数据库与运行态提交。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
persistence: PluginPersistenceService,
|
||
installed_plugins_reader: InstalledPluginsReader,
|
||
plugin_ids_provider: PluginIdsProvider,
|
||
packages: PluginPackageTransactionPort,
|
||
install_reporter: InstallReporter,
|
||
target_reloader: PluginReloader,
|
||
rollback_reloader: PluginReloader,
|
||
registration_refresher: PluginRegistrationRefresher,
|
||
mutation: PluginMutationAdmission,
|
||
package_write_guard: PluginPackageWriteGuard,
|
||
clock: Callable[[], datetime],
|
||
transaction_id_factory: Callable[[], str],
|
||
) -> None:
|
||
"""保存单一安装事务所需的窄端口。"""
|
||
self.__persistence = persistence
|
||
self.__installed_plugins_reader = installed_plugins_reader
|
||
self.__plugin_ids_provider = plugin_ids_provider
|
||
self.__packages = packages
|
||
self.__install_reporter = install_reporter
|
||
self.__target_reloader = target_reloader
|
||
self.__rollback_reloader = rollback_reloader
|
||
self.__registration_refresher = registration_refresher
|
||
self.__mutation = mutation
|
||
self.__package_write_guard = package_write_guard
|
||
self.__clock = clock
|
||
self.__transaction_id_factory = transaction_id_factory
|
||
|
||
async def execute(
|
||
self,
|
||
*,
|
||
admission: PluginInstallAdmission,
|
||
release_version: str | None,
|
||
force: bool,
|
||
local_sync: bool = False,
|
||
) -> PluginInstallResult:
|
||
"""执行已准入事务;共享 Python 依赖不属于文件与数据库补偿边界。"""
|
||
plugin_id = admission.candidate.plugin_id
|
||
state = _InstallState(transaction_id=self.__transaction_id_factory())
|
||
try:
|
||
with self.__mutation(f"安装插件 {plugin_id}"):
|
||
with self.__package_write_guard(plugin_id):
|
||
try:
|
||
return await self.__execute_locked(
|
||
admission=admission,
|
||
release_version=release_version,
|
||
force=force,
|
||
local_sync=local_sync,
|
||
state=state,
|
||
)
|
||
except asyncio.CancelledError:
|
||
await self.__rollback_cancelled(
|
||
plugin_id=plugin_id,
|
||
state=state,
|
||
)
|
||
raise
|
||
except PluginMutationRejectedError as error:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=str(error),
|
||
failure_stage="admission",
|
||
)
|
||
|
||
async def __execute_locked(
|
||
self,
|
||
*,
|
||
admission: PluginInstallAdmission,
|
||
release_version: str | None,
|
||
force: bool,
|
||
local_sync: bool,
|
||
state: _InstallState,
|
||
) -> PluginInstallResult:
|
||
"""执行文件准备、运行态验证和数据库最终提交。"""
|
||
candidate = admission.candidate
|
||
plugin_id = candidate.plugin_id
|
||
membership_before = any(
|
||
item.lower() == plugin_id.lower()
|
||
for item in (self.__installed_plugins_reader() or [])
|
||
)
|
||
if (
|
||
not force
|
||
and not local_sync
|
||
and release_version is None
|
||
and any(
|
||
item.lower() == plugin_id.lower()
|
||
for item in self.__plugin_ids_provider()
|
||
)
|
||
and await self.__payload_matches(admission)
|
||
):
|
||
return await self.__refresh_existing(
|
||
plugin_id,
|
||
candidate.repo_url,
|
||
report=not isinstance(candidate, PluginLocalCandidate),
|
||
)
|
||
|
||
try:
|
||
async def create_checkpoint() -> None:
|
||
"""在取消传播前保存已经创建完成的恢复材料引用。"""
|
||
state.checkpoint = await self.__packages.async_checkpoint(
|
||
plugin_id,
|
||
state.transaction_id,
|
||
)
|
||
|
||
await self.__await_side_effect(create_checkpoint())
|
||
except Exception as error:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=f"创建插件安装快照失败:{error}",
|
||
failure_stage="package_checkpoint",
|
||
)
|
||
|
||
now = self.__clock()
|
||
record = PluginInstallationRecord(
|
||
transaction_id=state.transaction_id,
|
||
plugin_id=plugin_id,
|
||
phase=PluginInstallationPhase.PREPARED,
|
||
membership_before=membership_before,
|
||
membership_target=None,
|
||
identity_before_revision=admission.expected_revision,
|
||
identity_target_revision=None,
|
||
package_existed=bool(state.checkpoint.plugin_existed),
|
||
persistent_backup_existed=bool(
|
||
state.checkpoint.persistent_backup_existed
|
||
),
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
try:
|
||
async def create_journal() -> None:
|
||
"""在取消传播前记录 PREPARED 已持久化。"""
|
||
await self.__persistence.create_installation(record)
|
||
state.journal_created = True
|
||
|
||
await self.__await_side_effect(create_journal())
|
||
except asyncio.CancelledError:
|
||
if not state.journal_created:
|
||
await self.__resolve_journal_created(state)
|
||
raise
|
||
except PluginInstallationConflictError as error:
|
||
rollback = await self.__rollback_without_journal(state.checkpoint)
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=str(error),
|
||
failure_stage="journal_prepare_conflict",
|
||
rollback=rollback,
|
||
)
|
||
except Exception as error:
|
||
journal_created = await self.__resolve_journal_created(state)
|
||
if journal_created is None:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=(
|
||
"插件安装事务创建结果暂时无法确认,已保留恢复材料,"
|
||
"重启后将自动核对"
|
||
),
|
||
failure_stage="journal_prepare_unknown",
|
||
)
|
||
rollback = (
|
||
await self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
if journal_created
|
||
else await self.__rollback_without_journal(state.checkpoint)
|
||
)
|
||
if isinstance(error, PersistenceUnavailableError):
|
||
raise
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=f"创建插件安装事务失败:{error}",
|
||
failure_stage="journal_prepare",
|
||
rollback=rollback,
|
||
)
|
||
|
||
state.stage = "package_install"
|
||
try:
|
||
package_installed, message = await self.__await_side_effect(
|
||
self.__packages.async_install(
|
||
plugin_id=plugin_id,
|
||
repo_url=candidate.repo_url,
|
||
package_version=candidate.package_generation,
|
||
release_version=release_version,
|
||
force_install=True,
|
||
)
|
||
)
|
||
state.package_installed = package_installed
|
||
except Exception as error:
|
||
if isinstance(error, PersistenceUnavailableError):
|
||
await self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
raise
|
||
return await self.__failure_result(
|
||
plugin_id=plugin_id,
|
||
state=state,
|
||
stage="package_install",
|
||
message=str(error),
|
||
)
|
||
if not package_installed:
|
||
return await self.__failure_result(
|
||
plugin_id=plugin_id,
|
||
state=state,
|
||
stage="package_install",
|
||
message=message,
|
||
)
|
||
|
||
try:
|
||
state.stage = "payload_receipt"
|
||
receipt = await self.__await_side_effect(
|
||
self.__packages.async_payload_receipt(plugin_id)
|
||
)
|
||
state.target_identity = admission.build_identity(
|
||
payload_receipt=receipt,
|
||
applied_at=self.__clock(),
|
||
declared_version=release_version,
|
||
manifest_matches_payload=(
|
||
release_version is None
|
||
or release_version == candidate.plugin_version
|
||
),
|
||
)
|
||
await self.__await_side_effect(
|
||
self.__persistence.set_installation_target(
|
||
state.transaction_id,
|
||
membership_target=True,
|
||
identity_target=state.target_identity,
|
||
)
|
||
)
|
||
|
||
state.stage = "persistent_backup_stage"
|
||
await self.__await_side_effect(
|
||
self.__packages.async_stage_persistent_backup(state.checkpoint)
|
||
)
|
||
state.stage = "persistent_backup_activate"
|
||
await self.__await_side_effect(
|
||
self.__packages.async_activate_persistent_backup(state.checkpoint)
|
||
)
|
||
|
||
state.stage = "runtime_reload"
|
||
state.runtime_touched = True
|
||
await self.__await_side_effect(self.__target_reloader(plugin_id))
|
||
state.stage = "registration_refresh"
|
||
state.registrations_touched = True
|
||
await self.__await_side_effect(
|
||
self.__registration_refresher(plugin_id)
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as error:
|
||
if isinstance(error, PersistenceUnavailableError):
|
||
await self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
raise
|
||
return await self.__failure_result(
|
||
plugin_id=plugin_id,
|
||
state=state,
|
||
stage=state.stage,
|
||
message=str(error),
|
||
)
|
||
|
||
state.stage = "database_commit"
|
||
try:
|
||
async def commit_database() -> None:
|
||
"""仅在数据库调用明确返回后标记提交已确认。"""
|
||
await self.__persistence.commit_installation(
|
||
state.transaction_id,
|
||
identity_target=state.target_identity,
|
||
)
|
||
state.committed = True
|
||
|
||
await self.__await_side_effect(commit_database())
|
||
except asyncio.CancelledError:
|
||
await self.__resolve_commit_outcome(state)
|
||
raise
|
||
except Exception as error:
|
||
outcome = await self.__resolve_commit_outcome(state)
|
||
if outcome is None:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=(
|
||
"插件数据库提交结果暂时无法确认,已保留安装事务,"
|
||
"重启后将自动恢复"
|
||
),
|
||
package_installed=True,
|
||
runtime_reloaded=True,
|
||
registrations_refreshed=True,
|
||
failure_stage="database_commit_unknown",
|
||
)
|
||
if not outcome:
|
||
if isinstance(error, PersistenceUnavailableError):
|
||
await self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
raise
|
||
return await self.__failure_result(
|
||
plugin_id=plugin_id,
|
||
state=state,
|
||
stage="database_commit",
|
||
message=str(error),
|
||
)
|
||
logger.warning(
|
||
"插件安装事务 %s 提交返回异常,但数据库已确认 COMMITTED:%s",
|
||
state.transaction_id,
|
||
error,
|
||
)
|
||
|
||
checkpoint_cleanup_error = await self.__finish_committed(state)
|
||
reported, report_error = await self.__report(
|
||
plugin_id,
|
||
candidate.repo_url,
|
||
enabled=(
|
||
not local_sync
|
||
and not isinstance(candidate, PluginLocalCandidate)
|
||
),
|
||
)
|
||
result_message = message or "插件安装成功"
|
||
if checkpoint_cleanup_error:
|
||
result_message = f"{result_message};安装事务待下次启动继续清理"
|
||
if report_error:
|
||
result_message = f"{result_message};安装上报失败,不影响本地安装"
|
||
return PluginInstallResult(
|
||
success=True,
|
||
message=result_message,
|
||
package_installed=True,
|
||
installed_list_persisted=True,
|
||
runtime_reloaded=True,
|
||
registrations_refreshed=True,
|
||
reported=reported,
|
||
report_error=report_error,
|
||
checkpoint_cleanup_error=checkpoint_cleanup_error,
|
||
)
|
||
|
||
async def __payload_matches(self, admission: PluginInstallAdmission) -> bool:
|
||
"""确认身份元数据与当前运行目录收据都描述同一载荷。"""
|
||
identity = admission.identity_before
|
||
candidate = admission.candidate
|
||
if identity is None or identity.payload_source_type is PluginPayloadSourceType.UNKNOWN:
|
||
return False
|
||
if (
|
||
identity.declared_version != candidate.plugin_version
|
||
or identity.package_generation != candidate.package_generation
|
||
or identity.payload_source_type is not candidate.payload_source_type
|
||
):
|
||
return False
|
||
source_matches = (
|
||
identity.payload_source_key is None
|
||
if isinstance(candidate, PluginLocalCandidate)
|
||
else identity.payload_source_key == candidate.source_key
|
||
)
|
||
if not source_matches or identity.payload_receipt is None:
|
||
return False
|
||
try:
|
||
current_receipt = await self.__await_side_effect(
|
||
self.__packages.async_payload_receipt(candidate.plugin_id)
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 无法证明相同就走完整安装
|
||
logger.warning(
|
||
"读取插件 %s 当前载荷收据失败,将重新安装:%s",
|
||
candidate.plugin_id,
|
||
error,
|
||
)
|
||
return False
|
||
return current_receipt == identity.payload_receipt
|
||
|
||
async def __refresh_existing(
|
||
self,
|
||
plugin_id: str,
|
||
repo_url: str | None,
|
||
*,
|
||
report: bool,
|
||
) -> PluginInstallResult:
|
||
"""刷新已经提交且载荷事实未变化的插件运行态。"""
|
||
failure_stage = "runtime_reload"
|
||
try:
|
||
await self.__await_side_effect(self.__target_reloader(plugin_id))
|
||
failure_stage = "registration_refresh"
|
||
await self.__await_side_effect(
|
||
self.__registration_refresher(plugin_id)
|
||
)
|
||
except Exception as error:
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=f"刷新插件运行态失败:{error}",
|
||
refreshed_only=True,
|
||
failure_stage=failure_stage,
|
||
)
|
||
reported, report_error = await self.__report(
|
||
plugin_id,
|
||
repo_url,
|
||
enabled=report,
|
||
)
|
||
return PluginInstallResult(
|
||
success=True,
|
||
message=(
|
||
"插件已存在,已刷新加载"
|
||
if not report_error
|
||
else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新"
|
||
),
|
||
refreshed_only=True,
|
||
runtime_reloaded=True,
|
||
registrations_refreshed=True,
|
||
reported=reported,
|
||
report_error=report_error,
|
||
)
|
||
|
||
async def __finish_committed(self, state: _InstallState) -> str:
|
||
"""幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。"""
|
||
try:
|
||
await self.__await_side_effect(
|
||
self.__packages.async_finalize_persistent_backup(state.checkpoint)
|
||
)
|
||
await self.__await_side_effect(
|
||
self.__packages.async_commit(state.checkpoint)
|
||
)
|
||
await self.__await_side_effect(
|
||
self.__persistence.delete_installation(
|
||
state.transaction_id,
|
||
expected_phase=PluginInstallationPhase.COMMITTED,
|
||
)
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 已提交终态不能反向回滚
|
||
logger.warning(
|
||
"插件安装事务 %s 已提交但清理未完成:%s",
|
||
state.transaction_id,
|
||
error,
|
||
)
|
||
return str(error)
|
||
return ""
|
||
|
||
async def __resolve_journal_created(
|
||
self,
|
||
state: _InstallState,
|
||
) -> bool | None:
|
||
"""PREPARED 创建确认异常时核对事务是否已经持久化。"""
|
||
if state.journal_created:
|
||
return True
|
||
task = asyncio.create_task(
|
||
self.__persistence.get_installation(state.transaction_id)
|
||
)
|
||
try:
|
||
record = await await_task_to_terminal(task)
|
||
except BaseException as error:
|
||
state.journal_unknown = True
|
||
logger.error(
|
||
"插件安装事务 %s 无法确认 PREPARED 创建结果:%s",
|
||
state.transaction_id,
|
||
error,
|
||
)
|
||
return None
|
||
if record is None:
|
||
state.journal_unknown = False
|
||
return False
|
||
if record.phase is not PluginInstallationPhase.PREPARED:
|
||
state.journal_unknown = True
|
||
logger.error(
|
||
"插件安装事务 %s 在 PREPARED 创建确认时阶段异常:%s",
|
||
state.transaction_id,
|
||
record.phase.value,
|
||
)
|
||
return None
|
||
state.journal_created = True
|
||
state.journal_unknown = False
|
||
return True
|
||
|
||
async def __resolve_commit_outcome(
|
||
self,
|
||
state: _InstallState,
|
||
) -> bool | None:
|
||
"""提交确认异常时读取 journal,拒绝猜测数据库最终状态。"""
|
||
if state.committed:
|
||
return True
|
||
task = asyncio.create_task(
|
||
self.__persistence.get_installation(state.transaction_id)
|
||
)
|
||
try:
|
||
record = await await_task_to_terminal(task)
|
||
except BaseException as error: # 数据库仍不可用时只能留待启动恢复
|
||
state.commit_unknown = True
|
||
logger.error(
|
||
"插件安装事务 %s 无法确认数据库提交结果:%s",
|
||
state.transaction_id,
|
||
error,
|
||
)
|
||
return None
|
||
if record is None:
|
||
state.commit_unknown = True
|
||
logger.error(
|
||
"插件安装事务 %s 在提交确认时已不存在",
|
||
state.transaction_id,
|
||
)
|
||
return None
|
||
if record.phase is PluginInstallationPhase.COMMITTED:
|
||
state.committed = True
|
||
state.commit_unknown = False
|
||
return True
|
||
state.commit_unknown = False
|
||
return False
|
||
|
||
async def __failure_result(
|
||
self,
|
||
*,
|
||
plugin_id: str,
|
||
state: _InstallState,
|
||
stage: str,
|
||
message: str,
|
||
) -> PluginInstallResult:
|
||
"""补偿 PREPARED 事务,并把失败阶段与恢复结果返回调用方。"""
|
||
rollback = await self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
return PluginInstallResult(
|
||
success=False,
|
||
message=message,
|
||
package_installed=state.package_installed,
|
||
failure_stage=stage,
|
||
rollback=rollback,
|
||
)
|
||
|
||
async def __fail_prepared(
|
||
self,
|
||
*,
|
||
plugin_id: str,
|
||
state: _InstallState,
|
||
) -> PluginInstallRollback:
|
||
"""恢复数据库提交前的文件和运行态,成功后删除 PREPARED journal。"""
|
||
errors: list[str] = []
|
||
file_restored = False
|
||
journal_deleted = False
|
||
try:
|
||
await self.__packages.async_restore(state.checkpoint)
|
||
file_restored = True
|
||
except Exception as error: # noqa: BLE001 - 保留 journal 供下次启动重试
|
||
errors.append(f"插件文件恢复失败:{error}")
|
||
|
||
runtime_restored = False
|
||
registrations_restored = False
|
||
if file_restored and state.runtime_touched:
|
||
try:
|
||
await self.__rollback_reloader(plugin_id)
|
||
runtime_restored = True
|
||
except Exception as error: # noqa: BLE001 - 返回完整补偿诊断
|
||
errors.append(f"插件运行态恢复失败:{error}")
|
||
if runtime_restored:
|
||
try:
|
||
await self.__registration_refresher(plugin_id)
|
||
registrations_restored = True
|
||
except Exception as error: # noqa: BLE001
|
||
errors.append(f"插件注册恢复失败:{error}")
|
||
|
||
rollback_complete = file_restored and (
|
||
not state.runtime_touched
|
||
or (runtime_restored and registrations_restored)
|
||
)
|
||
if rollback_complete and state.journal_created:
|
||
try:
|
||
await self.__persistence.delete_installation(
|
||
state.transaction_id,
|
||
expected_phase=PluginInstallationPhase.PREPARED,
|
||
)
|
||
journal_deleted = True
|
||
except Exception as error: # noqa: BLE001 - marker 让恢复可安全重试
|
||
errors.append(f"插件安装事务删除失败:{error}")
|
||
if journal_deleted:
|
||
try:
|
||
await self.__packages.async_cleanup(state.checkpoint)
|
||
except Exception as error: # noqa: BLE001 - orphan 不再影响业务终态
|
||
errors.append(f"插件恢复材料清理失败:{error}")
|
||
|
||
return PluginInstallRollback(
|
||
file_attempted=state.checkpoint is not None,
|
||
file_restored=file_restored,
|
||
runtime_attempted=state.runtime_touched,
|
||
runtime_restored=runtime_restored,
|
||
registrations_attempted=(
|
||
state.runtime_touched or state.registrations_touched
|
||
),
|
||
registrations_restored=registrations_restored,
|
||
journal_deleted=journal_deleted,
|
||
errors=tuple(errors),
|
||
)
|
||
|
||
async def __rollback_without_journal(self, checkpoint: Any) -> PluginInstallRollback:
|
||
"""journal 创建失败时恢复文件并立即清理无主快照。"""
|
||
errors: list[str] = []
|
||
try:
|
||
await self.__packages.async_restore(checkpoint)
|
||
except Exception as error: # noqa: BLE001
|
||
return PluginInstallRollback(
|
||
file_attempted=True,
|
||
errors=(f"插件文件恢复失败:{error}",),
|
||
)
|
||
try:
|
||
await self.__packages.async_cleanup(checkpoint)
|
||
except Exception as error: # noqa: BLE001 - 无 journal 的孤儿不影响旧载荷
|
||
errors.append(f"插件恢复材料清理失败:{error}")
|
||
return PluginInstallRollback(
|
||
file_attempted=True,
|
||
file_restored=True,
|
||
errors=tuple(errors),
|
||
)
|
||
|
||
async def __rollback_cancelled(
|
||
self,
|
||
*,
|
||
plugin_id: str,
|
||
state: _InstallState,
|
||
) -> None:
|
||
"""保留取消语义,但不在补偿完成前释放插件生命周期 owner。"""
|
||
if state.committed:
|
||
logger.warning(
|
||
"插件 %s 在数据库提交后被取消,安装终态将在启动时继续清理",
|
||
plugin_id,
|
||
)
|
||
return
|
||
if state.commit_unknown:
|
||
logger.error(
|
||
"插件 %s 在数据库提交结果未知时被取消,保留 journal 和当前载荷等待启动恢复",
|
||
plugin_id,
|
||
)
|
||
return
|
||
if state.journal_unknown:
|
||
logger.error(
|
||
"插件 %s 在 PREPARED 创建结果未知时被取消,保留恢复材料等待人工或启动核对",
|
||
plugin_id,
|
||
)
|
||
return
|
||
if state.checkpoint is None:
|
||
return
|
||
cleanup_task = asyncio.create_task(
|
||
self.__fail_prepared(plugin_id=plugin_id, state=state)
|
||
if state.journal_created
|
||
else self.__rollback_without_journal(state.checkpoint)
|
||
)
|
||
try:
|
||
rollback = await await_task_to_terminal(cleanup_task)
|
||
except BaseException as error:
|
||
logger.error("插件 %s 取消后的补偿失败:%s", plugin_id, error)
|
||
return
|
||
if rollback.errors:
|
||
logger.error(
|
||
"插件 %s 取消后的补偿存在错误:%s",
|
||
plugin_id,
|
||
";".join(rollback.errors),
|
||
)
|
||
|
||
@staticmethod
|
||
async def __await_side_effect(operation: Awaitable[T]) -> T:
|
||
"""让不可安全中断的副作用进入终态后再传播调用方取消。"""
|
||
task = asyncio.ensure_future(operation)
|
||
try:
|
||
return await asyncio.shield(task)
|
||
except asyncio.CancelledError as cancellation:
|
||
try:
|
||
await await_task_to_terminal(task)
|
||
except BaseException as error:
|
||
raise cancellation from error
|
||
raise
|
||
|
||
async def __report(
|
||
self,
|
||
plugin_id: str,
|
||
repo_url: str | None,
|
||
*,
|
||
enabled: bool,
|
||
) -> tuple[bool, str]:
|
||
"""执行非关键远程上报,不改变本地安装终态。"""
|
||
if not enabled:
|
||
return False, ""
|
||
try:
|
||
result = await self.__install_reporter(plugin_id, repo_url)
|
||
return result is not False, "" if result is not False else "安装上报未确认"
|
||
except Exception as error: # noqa: BLE001 - 远程上报不回滚本地安装
|
||
return False, str(error)
|