Newer
Older
TIASDataHub / src / tiasdata / backup.py
"""目録と対応表のバックアップ(`tiasdata backup`.SPEC_03「CLI」).

目録(`catalog.sqlite`)は `<catalog.root>/backups/catalog-<日時>.sqlite` に,対応表
(`identity.sqlite`)は**対応表のフォルダの** `backups/identity-<日時>.sqlite` に複製する
(対応表を目録のフォルダに置かないため.SPEC_01「構成要素」).日時は UTC の
`YYYYmmddTHHMMSSZ` で,2 つの複製で同じ値にする.

複製は「一時ファイルに SQLite のバックアップ API で書く → `PRAGMA integrity_check` とスキーマの
版を確かめる → 正式な名前にする」の順で行い,書きかけを正式な名前で残さない.既存のファイルは
上書きしない(同じ名前があれば停止する).出力にはファイル名と件数だけを出す(パスは出さない).
"""

import os
import sqlite3
import tempfile
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path

from pydantic import BaseModel, ConfigDict

from tiasdata import catalog
from tiasdata.catalog import backup as catalog_backup
from tiasdata.config import Config

BACKUPS_DIRNAME = "backups"
TEMP_PREFIX = ".tmp-"
_TIMESTAMP_FORMAT = "%Y%m%dT%H%M%SZ"


class BackupFile(BaseModel):
    """1 つの複製の結果.

    Attributes:
        filename: 複製のファイル名(`backups/` の中.パスは含まない).
        counts: 表の名前 → 行の数.
    """

    model_config = ConfigDict(frozen=True)

    filename: str
    counts: dict[str, int]


class BackupResult(BaseModel):
    """バックアップの結果(どちらの複製も `PRAGMA integrity_check` が ok だったもの).

    Attributes:
        timestamp: 複製の名前に付けた日時(UTC の `YYYYmmddTHHMMSSZ`).
        catalog: 目録の複製.
        identity: 対応表の複製.
    """

    model_config = ConfigDict(frozen=True)

    timestamp: str
    catalog: BackupFile
    identity: BackupFile


def backup_timestamp(now: datetime | None = None) -> str:
    """複製の名前に付ける日時(UTC の `YYYYmmddTHHMMSSZ`)を返す.

    Args:
        now: 日時(省略時は現在.タイムゾーンなしは UTC とみなす).

    Returns:
        `20261008T090000Z` の形の文字列.
    """
    moment = now if now is not None else datetime.now(UTC)
    if moment.tzinfo is not None:
        moment = moment.astimezone(UTC)
    return moment.strftime(_TIMESTAMP_FORMAT)


def _publish(tmp_path: Path, target: Path) -> None:
    # 一時ファイルを正式な名前にする.既に同じ名前があれば上書きせずに停止する(store と同じ方法)
    try:
        if os.name == "nt":
            # Windows の rename は移動先があると FileExistsError になり,上書きしない
            os.rename(tmp_path, target)
        else:
            # POSIX の rename は黙って上書きするため,既存があれば失敗する link を使う
            os.link(tmp_path, target)
            tmp_path.unlink()
    except FileExistsError:
        raise ValueError(f"バックアップ {target.name} が既にある(上書きしない)") from None


def _make_copy(source: Path, directory: Path, label: str, series: catalog.Series) -> Path:
    """一時ファイルに複製し,整合性とスキーマの版を確かめて一時ファイルのパスを返す."""
    fd, tmp_name = tempfile.mkstemp(prefix=TEMP_PREFIX, suffix=".sqlite", dir=directory)
    os.close(fd)
    tmp_path = Path(tmp_name)
    try:
        try:
            catalog_backup.copy_database(source, tmp_path)
        except sqlite3.Error as e:
            # SQLite のメッセージは理由(ロック・容量等)だけで,パスや値を含まない
            raise ValueError(f"{label}を複製できない({type(e).__name__}: {e})") from e
        problems = catalog_backup.integrity_problems(tmp_path)
        if problems:
            raise ValueError(
                f"{label}の複製の整合性の点検(integrity_check)が ok でない({problems} 件)."
                "複製は残さない"
            )
        version = catalog_backup.user_version(tmp_path)
        latest = catalog.latest_version(series)
        if version != latest:
            raise ValueError(
                f"{label}の複製のスキーマの版 {version} が最新 {latest} でない.複製は残さない"
            )
    except BaseException:
        tmp_path.unlink(missing_ok=True)
        raise
    return tmp_path


def _require_sources(config: Config) -> None:
    # init 前・スキーマが古いときは複製せずに 1 行のエラーにする
    paths = config.catalog
    with closing(catalog.connect_latest(paths.catalog_path)):
        pass
    if not paths.identity.is_file():
        raise ValueError("対応表が無い.先に `tiasdata init` を実行する")
    with closing(catalog.connect(paths.identity)) as conn:
        catalog.require_latest(conn, "identity")


def run_backup(config: Config, *, now: datetime | None = None) -> BackupResult:
    """目録と対応表を,それぞれのフォルダの `backups/` に複製して点検する.

    Args:
        config: 設定.
        now: 複製の名前に付ける日時(省略時は現在).

    Returns:
        バックアップの結果(ファイル名と件数).

    Raises:
        ValueError: 目録・対応表が無い,スキーマが最新でない,同じ名前の複製が既にある,
            または複製の整合性の点検が ok でない(いずれも正式な名前の複製は残さない.
            2 つ目の複製を正式な名前にするところで失敗した場合だけ,1 つ目は残る).
    """
    _require_sources(config)
    paths = config.catalog
    timestamp = backup_timestamp(now)
    catalog_dir = paths.backups_dir
    identity_dir = paths.identity.parent / BACKUPS_DIRNAME
    catalog_dir.mkdir(exist_ok=True)
    identity_dir.mkdir(exist_ok=True)
    catalog_target = catalog_dir / f"catalog-{timestamp}.sqlite"
    identity_target = identity_dir / f"identity-{timestamp}.sqlite"
    for target in (catalog_target, identity_target):
        if target.exists():
            raise ValueError(f"バックアップ {target.name} が既にある(上書きしない)")

    temps: list[Path] = []
    try:
        catalog_tmp = _make_copy(paths.catalog_path, catalog_dir, "目録", "catalog")
        temps.append(catalog_tmp)
        identity_tmp = _make_copy(paths.identity, identity_dir, "対応表", "identity")
        temps.append(identity_tmp)
        catalog_counts = catalog_backup.count_catalog_rows(catalog_tmp)
        identity_counts = {"identity_map": catalog_backup.count_identity_rows(identity_tmp)}
        _publish(catalog_tmp, catalog_target)
        _publish(identity_tmp, identity_target)
    except BaseException:
        for tmp in temps:
            tmp.unlink(missing_ok=True)
        raise
    return BackupResult(
        timestamp=timestamp,
        catalog=BackupFile(filename=catalog_target.name, counts=catalog_counts),
        identity=BackupFile(filename=identity_target.name, counts=identity_counts),
    )