"""Открытие и жизненный цикл SQLite-хранилища состояния (issue #20, ADR-006). Путь по умолчанию — ``/data/awg-dash.db`` (персистентный volume контейнера), переопределяется аргументом или переменной окружения ``AWG_DASH_DB``. Каталог создаётся автоматически, поэтому на пустом ``/data`` схема v1 поднимается без ручных действий. Соединение открывается в режиме WAL: читатели не блокируют писателя, ``synchronous=NORMAL`` достаточен для WAL (риск потери только последних транзакций при падении операционной системы, БД при этом не разрушается). """ from __future__ import annotations import os import sqlite3 import threading from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from .errors import StorageError from .migrations import MigrationReport, current_version, migrate, verify_schema DEFAULT_DB_PATH = "/data/awg-dash.db" DB_PATH_ENV = "AWG_DASH_DB" BUSY_TIMEOUT_MS = 5000 _WAL_PRAGMAS: tuple[str, ...] = ( "PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL", "PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000", ) def resolve_db_path(path: str | os.PathLike[str] | None = None) -> Path: """Определить путь к БД: аргумент -> ``AWG_DASH_DB`` -> ``/data/awg-dash.db``.""" if path is None: path = os.environ.get(DB_PATH_ENV) or DEFAULT_DB_PATH return Path(path) class Database: """Соединение SQLite: миграции, проверка схемы, транзакции.""" def __init__( self, path: str | os.PathLike[str] | None = None, *, auto_migrate: bool = True, ) -> None: self.path = resolve_db_path(path) self._lock = threading.RLock() self._conn = self._open(self.path) self.migration_report: MigrationReport | None = None if auto_migrate: self.migrate() else: verify_schema(self._conn) # -- соединение ----------------------------------------------------- @staticmethod def _open(path: Path) -> sqlite3.Connection: if str(path) != ":memory:": try: path.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: raise StorageError(f"не удалось создать каталог {path.parent}: {exc}") from exc try: conn = sqlite3.connect( str(path), isolation_level=None, check_same_thread=False, timeout=BUSY_TIMEOUT_MS / 1000, ) conn.row_factory = sqlite3.Row for pragma in _WAL_PRAGMAS: conn.execute(pragma) except sqlite3.Error as exc: raise StorageError(f"не удалось открыть БД {path}: {exc}") from exc return conn @property def connection(self) -> sqlite3.Connection: """Открытое соединение (для сервисного слоя).""" return self._conn @property def journal_mode(self) -> str: row = self._conn.execute("PRAGMA journal_mode").fetchone() return str(row[0]) if row is not None else "" @property def is_wal(self) -> bool: return self.journal_mode.lower() == "wal" @property def schema_version(self) -> int: return current_version(self._conn) # -- схема ---------------------------------------------------------- def migrate(self) -> MigrationReport: """Применить неприменённые миграции и проверить схему.""" with self._lock: report = migrate(self._conn) self.migration_report = report return report def verify_schema(self) -> None: """Проверить соответствие фактической схемы ожидаемой версии.""" verify_schema(self._conn) # -- транзакции ----------------------------------------------------- @contextmanager def transaction(self) -> Iterator[sqlite3.Connection]: """Транзакция ALL-OR-NOTHING (``BEGIN IMMEDIATE`` + rollback при ошибке).""" with self._lock: self._conn.execute("BEGIN IMMEDIATE") try: yield self._conn except BaseException: self._conn.rollback() raise else: self._conn.commit() def close(self) -> None: with self._lock: try: self._conn.close() except sqlite3.Error as exc: # pragma: no cover - защита от гонок при shutdown raise StorageError(f"ошибка закрытия БД: {exc}") from exc def __enter__(self) -> Database: return self def __exit__(self, *exc_info: object) -> None: self.close()