feat(store): SQLite WAL, миграции, валидация, аудит, снимки состояния (#20)
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Открытие и жизненный цикл 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()
|
||||
Reference in New Issue
Block a user