140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""Открытие и жизненный цикл 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()
|