Files
MoviePilot/app/application/plugin/recovery.py
T

190 lines
6.7 KiB
Python

"""插件安装 journal 的启动恢复与已提交事务收尾。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from app.application.plugin.install import (
PluginPackageCheckpoint,
PluginPackageTransactionPort,
)
from app.application.plugin.transaction import (
PluginInstallationPhase,
PluginInstallationRecord,
PluginPersistenceService,
)
from app.runtime.log import logger
class PluginInstallationRecoveryError(RuntimeError):
"""恢复材料不足或已提交载荷事实不一致,不能继续导入插件。"""
class PluginRecoveryPackagePort(PluginPackageTransactionPort, Protocol):
"""启动恢复在安装包事务端口之上需要的重建与核验能力。"""
def restore_checkpoint(
self,
*,
plugin_id: str,
transaction_id: str,
plugin_existed: bool,
persistent_backup_existed: bool,
) -> PluginPackageCheckpoint:
"""只根据 journal 中的受限事实重建恢复路径。"""
async def async_committed_payload_receipt(
self,
checkpoint: PluginPackageCheckpoint,
) -> str:
"""读取当前部署模式下一次已提交载荷的恢复收据。"""
@dataclass(frozen=True, slots=True)
class PluginInstallationRecoveryResult:
"""启动恢复批次的 PREPARED 回滚和 COMMITTED 收尾数量。"""
restored: int = 0
finalized: int = 0
cleanup_pending: int = 0
class PluginInstallationRecoveryService:
"""在插件导入前把跨进程 journal 收敛到完整旧状态或新状态。"""
def __init__(
self,
*,
persistence: PluginPersistenceService,
packages: PluginRecoveryPackagePort,
) -> None:
"""保存数据库最终事实和文件恢复端口。"""
self.__persistence = persistence
self.__packages = packages
async def replay(self) -> PluginInstallationRecoveryResult:
"""按创建顺序恢复全部 journal;关键事实不一致时阻止插件启动。"""
restored = 0
finalized = 0
cleanup_pending = 0
for record in await self.__persistence.list_installations():
checkpoint = self.__checkpoint(record)
if record.phase is PluginInstallationPhase.PREPARED:
await self.__restore_prepared(record, checkpoint)
restored += 1
continue
if await self.__finish_committed(record, checkpoint):
finalized += 1
else:
cleanup_pending += 1
return PluginInstallationRecoveryResult(
restored=restored,
finalized=finalized,
cleanup_pending=cleanup_pending,
)
def __checkpoint(
self,
record: PluginInstallationRecord,
) -> PluginPackageCheckpoint:
"""把 journal 映射为受控恢复路径,不读取任意持久化路径。"""
return self.__packages.restore_checkpoint(
plugin_id=record.plugin_id,
transaction_id=record.transaction_id,
plugin_existed=record.package_existed,
persistent_backup_existed=record.persistent_backup_existed,
)
async def __restore_prepared(
self,
record: PluginInstallationRecord,
checkpoint: PluginPackageCheckpoint,
) -> None:
"""恢复数据库提交前的文件状态,再释放 journal 所有权。"""
try:
await self.__packages.async_restore(checkpoint)
await self.__persistence.delete_installation(
record.transaction_id,
expected_phase=PluginInstallationPhase.PREPARED,
)
except Exception as error:
raise PluginInstallationRecoveryError(
f"插件 {record.plugin_id} 的未提交安装恢复失败:{error}"
) from error
try:
await self.__packages.async_cleanup(checkpoint)
except Exception as error: # journal 已删除,孤儿材料不改变业务终态
logger.warning(
"插件安装事务 %s 已恢复,但恢复材料清理失败:%s",
record.transaction_id,
error,
)
async def __finish_committed(
self,
record: PluginInstallationRecord,
checkpoint: PluginPackageCheckpoint,
) -> bool:
"""核验并收尾已提交载荷;非关键清理失败留待下一次启动。"""
identity = await self.__persistence.get_identity(record.plugin_id)
if identity is None or identity.revision != record.identity_target_revision:
raise PluginInstallationRecoveryError(
f"插件 {record.plugin_id} 的已提交身份与安装 journal 不一致"
)
try:
receipt = await self.__packages.async_committed_payload_receipt(
checkpoint
)
except Exception as error:
raise PluginInstallationRecoveryError(
f"插件 {record.plugin_id} 的已提交载荷无法核验:{error}"
) from error
if receipt != identity.payload_receipt:
raise PluginInstallationRecoveryError(
f"插件 {record.plugin_id} 的已提交载荷收据不一致"
)
try:
await self.__packages.async_finalize_persistent_backup(checkpoint)
except Exception as error:
raise PluginInstallationRecoveryError(
f"插件 {record.plugin_id} 的持久备份终态不完整:{error}"
) from error
try:
await self.__packages.async_commit(checkpoint)
await self.__persistence.delete_installation(
record.transaction_id,
expected_phase=PluginInstallationPhase.COMMITTED,
)
except Exception as error:
logger.warning(
"插件安装事务 %s 已提交但收尾仍待重试:%s",
record.transaction_id,
error,
)
return False
return True
_RECOVERY_SERVICE: list[PluginInstallationRecoveryService] = []
def configure_plugin_installation_recovery(
service: PluginInstallationRecoveryService,
) -> None:
"""由组合根登记当前 lifespan 的安装恢复服务。"""
_RECOVERY_SERVICE.clear()
_RECOVERY_SERVICE.append(service)
def get_plugin_installation_recovery() -> PluginInstallationRecoveryService:
"""返回已装配恢复服务;启动顺序错误时拒绝跳过恢复。"""
if not _RECOVERY_SERVICE:
raise RuntimeError("插件安装恢复服务尚未完成初始化")
return _RECOVERY_SERVICE[0]
def reset_plugin_installation_recovery() -> None:
"""清除当前 lifespan 的恢复服务。"""
_RECOVERY_SERVICE.clear()