Files
MoviePilot/app/application/backup.py
T

192 lines
6.7 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.
"""数据库备份与离线还原用例。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from threading import Lock
from typing import Callable, Protocol
from app.adapters.system.backup.files import BackupFiles
from app.runtime.log import logger
@dataclass(frozen=True, slots=True)
class BackupPolicy:
"""一次备份操作使用的目录与保留策略快照。"""
root: Path
retention_days: int = 0
max_count: int = 0
def __post_init__(self) -> None:
if self.retention_days < 0 or self.max_count < 0:
raise ValueError("数据库备份保留策略不能使用负数")
@dataclass(frozen=True, slots=True)
class BackupArtifact:
"""一个已完成校验并发布的数据库备份文件。"""
name: str
db_type: str
created_at: datetime
path: Path
size: int
@dataclass(frozen=True, slots=True)
class BackupVerification:
"""数据库备份文件的基础内容校验结果。"""
valid: bool
method: str
detail: str | None = None
class DatabaseBackupInProgressError(RuntimeError):
"""同一宿主进程已有数据库备份正在创建。"""
class BackupCheck(Protocol):
"""数据库适配器校验结果的结构合同。"""
valid: bool
method: str
detail: str | None
class DatabaseBackupBackend(Protocol):
"""活动数据库创建、校验和离线还原所需的最小技术端口。"""
db_type: str
suffix: str
def create(self, destination: Path) -> None:
"""把活动数据库的一致快照写入目标文件。"""
def verify(self, artifact: Path) -> BackupCheck:
"""在不修改数据库的前提下校验备份文件。"""
def restore(self, artifact: Path) -> None:
"""把已校验制品还原到离线目标数据库。"""
class DatabaseBackupService:
"""管理单文件数据库备份及明确的离线还原操作。"""
def __init__(
self,
*,
backend: DatabaseBackupBackend,
policy_reader: Callable[[], BackupPolicy],
clock: Callable[[], datetime] = datetime.now,
) -> None:
self._backend = backend
self._policy_reader = policy_reader
self._clock = clock
self._create_lock = Lock()
def create(self) -> BackupArtifact:
"""创建、校验并发布一个在线一致快照。"""
if not self._create_lock.acquire(blocking=False):
raise DatabaseBackupInProgressError("已有数据库备份任务正在执行")
try:
policy = self._policy_reader()
files = BackupFiles(policy.root)
created_at = self._clock()
name = files.available_name(
db_type=self._backend.db_type,
created_at=created_at,
suffix=self._backend.suffix,
)
temporary = files.create_temporary(self._backend.suffix)
try:
self._backend.create(temporary)
verification = self._backend.verify(temporary)
if not verification.valid:
detail = f"{verification.detail}" if verification.detail else ""
raise RuntimeError(
f"数据库备份校验失败({verification.method}{detail}"
)
path = files.publish(temporary, name)
except Exception:
files.discard(temporary)
raise
artifact = self._artifact(path, created_at=created_at)
self._prune(files, policy, keep=artifact.name)
logger.info(
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type}"
f"大小={artifact.size} bytes"
)
return artifact
finally:
self._create_lock.release()
def list(self) -> tuple[BackupArtifact, ...]:
"""按创建时间倒序列出受管数据库备份文件。"""
files = BackupFiles(self._policy_reader().root)
return tuple(self._artifact(path) for path in files.list())
def verify(self, name: str) -> BackupVerification:
"""按文件名校验一个受管数据库备份。"""
path = BackupFiles(self._policy_reader().root).resolve(name)
self._require_matching_type(path)
result = self._backend.verify(path)
return BackupVerification(result.valid, result.method, result.detail)
def delete(self, name: str) -> None:
"""删除一个受管数据库备份文件。"""
BackupFiles(self._policy_reader().root).delete(name)
def restore(self, name: str) -> BackupArtifact:
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
path = BackupFiles(self._policy_reader().root).resolve(name)
self._require_matching_type(path)
verification = self._backend.verify(path)
if not verification.valid:
detail = f"{verification.detail}" if verification.detail else ""
raise RuntimeError(
f"数据库备份校验失败({verification.method}{detail}"
)
self._backend.restore(path)
logger.info(
f"数据库离线还原完成:文件={path.name},类型={self._backend.db_type}"
)
return self._artifact(path)
def _prune(self, files: BackupFiles, policy: BackupPolicy, *, keep: str) -> None:
"""新备份发布成功后按天数或份数清理同一目录中的旧文件。"""
cutoff = (
self._clock() - timedelta(days=policy.retention_days)
if policy.retention_days > 0
else None
)
for index, path in enumerate(files.list()):
if path.name == keep:
continue
created_at = files.created_at(path.name)
expired = cutoff is not None and created_at < cutoff
exceeds_count = policy.max_count > 0 and index >= policy.max_count
if expired or exceeds_count:
files.delete(path.name)
def _require_matching_type(self, path: Path) -> None:
db_type = BackupFiles.database_type(path.name)
if db_type != self._backend.db_type:
raise ValueError(
f"备份类型 {db_type} 与当前数据库类型 {self._backend.db_type} 不一致"
)
@staticmethod
def _artifact(path: Path, *, created_at: datetime | None = None) -> BackupArtifact:
return BackupArtifact(
name=path.name,
db_type=BackupFiles.database_type(path.name),
created_at=created_at or BackupFiles.created_at(path.name),
path=path,
size=path.stat().st_size,
)