fix(#20): потокобезопасные чтения, BaseException в apply, old_value внутри транзакции (#54, #55)

Закрывает замечания ревью QA по PR #50:

* #54: все чтения Store идут через Database.read() под тем же RLock, что
  транзакции; контракт «одно соединение, все операции под локом»
  задокументирован в db.py и README модуля. Устраняет TypeError/IndexError
  на общем соединении, грязные чтения и выдачу состояния, которое затем
  откатывается падающим apply.
* #55 D-1: apply ловит BaseException — отмена/CancelledError/
  KeyboardInterrupt вызывают Applier.revert(previous) и пишут failed в
  audit_log (без «висящей» записи staged); падение revert попадает в detail.
* #55 D-2: set_geoip_schedule читает old_value внутри той же транзакции.

Refs #20, Refs #54, Refs #55
This commit is contained in:
2026-10-28 21:12:09 +03:00
parent 7e10ffaf8a
commit 249f32e3f9
4 changed files with 174 additions and 72 deletions
+44 -8
View File
@@ -5,9 +5,25 @@
Каталог создаётся автоматически, поэтому на пустом ``/data`` схема v1
поднимается без ручных действий.
Соединение открывается в режиме WAL: читатели не блокируют писателя,
``synchronous=NORMAL`` достаточен для WAL (риск потери только последних
транзакций при падении операционной системы, БД при этом не разрушается).
Соединение открывается в режиме WAL: ``synchronous=NORMAL`` достаточен для WAL
(риск потери только последних транзакций при падении операционной системы, БД
при этом не разрушается).
Контракт потокобезопасности (#54): соединение одно на процесс, все операции —
и записи (:meth:`Database.transaction`), и чтения (:meth:`Database.read`) —
выполняются под одним ``RLock``. Поэтому:
* результаты операторов из разных потоков не перемешиваются (нет
``TypeError``/``IndexError``);
* читатель не видит незафиксированных строк чужой транзакции и состояния,
которое затем будет откачено (падение внешнего применителя);
* конкурентные запросы ждут, не падают.
WAL при этом остаётся: он даёт устойчивость к падению процесса и позволяет
читать БД снаружи (``sqlite3`` CLI) во время работы шлюза. Если позже
понадобится параллельная пропускная способность чтений, контракт меняется на
«соединение на поток» (``threading.local``) — это отдельное решение, не
молчаливое поведение.
"""
from __future__ import annotations
@@ -83,12 +99,18 @@ class Database:
@property
def connection(self) -> sqlite3.Connection:
"""Открытое соединение (для сервисного слоя)."""
"""Прямой доступ к соединению — только для однопоточного кода.
Рантайм API/сервисного слоя использует :meth:`read` (чтения) и
:meth:`transaction` (мутации): они берут общий лок и гарантируют
согласованность результата (#54).
"""
return self._conn
@property
def journal_mode(self) -> str:
row = self._conn.execute("PRAGMA journal_mode").fetchone()
with self._lock:
row = self._conn.execute("PRAGMA journal_mode").fetchone()
return str(row[0]) if row is not None else ""
@property
@@ -97,7 +119,8 @@ class Database:
@property
def schema_version(self) -> int:
return current_version(self._conn)
with self._lock:
return current_version(self._conn)
# -- схема ----------------------------------------------------------
def migrate(self) -> MigrationReport:
@@ -109,9 +132,10 @@ class Database:
def verify_schema(self) -> None:
"""Проверить соответствие фактической схемы ожидаемой версии."""
verify_schema(self._conn)
with self._lock:
verify_schema(self._conn)
# -- транзакции -----------------------------------------------------
# -- транзакции и чтения --------------------------------------------
@contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
"""Транзакция ALL-OR-NOTHING (``BEGIN IMMEDIATE`` + rollback при ошибке)."""
@@ -125,6 +149,18 @@ class Database:
else:
self._conn.commit()
@contextmanager
def read(self) -> Iterator[sqlite3.Connection]:
"""Согласованное чтение под общим локом (#54).
Ждёт завершения чужой транзакции и потому не видит незафиксированных
строк и состояния, которое затем будет откачено. Внутри уже открытой
транзакции того же потока лок реентерабелен, поэтому читать можно и из
неё.
"""
with self._lock:
yield self._conn
def close(self) -> None:
with self._lock:
try: