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

202 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件安装 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:
"""启动恢复批次的成功、待清理和按插件隔离失败数量。"""
restored: int = 0
finalized: int = 0
cleanup_pending: int = 0
failed: 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
failed = 0
for record in await self.__persistence.list_installations():
try:
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
except Exception as error: # noqa: BLE001 - 单插件故障不得阻断宿主
failed += 1
logger.error(
"插件 %s 的安装恢复失败,已跳过本条恢复并保留 journal 供重试:%s",
record.plugin_id,
error,
exc_info=True,
)
return PluginInstallationRecoveryResult(
restored=restored,
finalized=finalized,
cleanup_pending=cleanup_pending,
failed=failed,
)
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()