mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
190 lines
6.7 KiB
Python
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()
|