Sprint 0 / #20: хранилище состояния — SQLite WAL, миграции, audit log, атомарный apply/rollback #50

Merged
embeddevio merged 8 commits from feat/20-storage into main 2026-10-28 21:35:42 +03:00
Showing only changes of commit c4661c9db9 - Show all commits
+308
View File
@@ -0,0 +1,308 @@
"""Тесты потокобезопасности слоя хранения (#54, D-1/D-2 из #55; issue #20).
Контракт (задокументирован в ``backend/awg_api/store/README.md``): одно
соединение SQLite, все операции — чтения и записи — сериализуются одним
``RLock``. Читатель никогда не видит незафиксированных строк и состояния,
которое затем будет откачено, а конкурентные мутации дают согласованный
журнал аудита.
"""
from __future__ import annotations
import asyncio
import threading
import time
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import pytest
from awg_api.store import (
AUDIT_STATUS_FAILED,
ApplyEngine,
ApplyError,
Database,
DesiredState,
Entity,
Mode,
Rule,
Store,
capture_state,
)
THREADS = 4
PER_THREAD = 25
JOIN_TIMEOUT = 30.0
# окно наблюдения: за это время незаблокированное чтение гарантированно успело бы вернуться
OBSERVE_SECONDS = 0.2
RULE_INSERT = (
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
def open_store(tmp_path: Path) -> tuple[Database, Store]:
db = Database(tmp_path / "data" / "awg-dash.db")
return db, Store(db)
def rule(tid: int, idx: int) -> Rule:
return Rule(
name=f"r{tid}x{idx}",
kind="domain",
match=f"t{tid}r{idx}.example.com",
target="tun0",
)
def run_threads(workers: list[Any]) -> list[threading.Thread]:
threads = [threading.Thread(target=worker) for worker in workers]
for thread in threads:
thread.start()
for thread in threads:
thread.join(JOIN_TIMEOUT)
assert not thread.is_alive(), f"поток {thread.name} не завершился"
return threads
# -- #54: конкурентные мутации вместе с чтениями ---------------------------------
def test_concurrent_upserts_and_reads_are_consistent(tmp_path: Path) -> None:
"""N потоков × M upsert с чтениями: без ошибок, rules == audit == N*M."""
db, store = open_store(tmp_path)
with db:
errors: list[str] = []
barrier = threading.Barrier(THREADS)
def worker(tid: int) -> None:
try:
barrier.wait(timeout=JOIN_TIMEOUT)
for idx in range(PER_THREAD):
store.upsert_rule(rule(tid, idx), actor=f"thread{tid}")
store.list_rules()
store.get_mode()
store.get_rule_by_name(f"r{tid}x{idx}")
except BaseException as exc: # noqa: BLE001 - фиксируем любое падение потока
errors.append(f"thread{tid}: {type(exc).__name__}: {exc}")
run_threads([lambda tid=tid: worker(tid) for tid in range(THREADS)])
total = THREADS * PER_THREAD
assert errors == []
assert store.counts()["rules"] == total
assert store.counts()["audit"] == total
assert len(store.list_rules()) == total
assert len(store.list_audit(limit=total * 2)) == total
# -- #54: чтение не видит незафиксированных строк -------------------------------
def test_reads_never_see_uncommitted_rows(tmp_path: Path) -> None:
"""Пока транзакция другого потока открыта, читатель ждёт коммита, а не читает грязь."""
db, store = open_store(tmp_path)
with db:
store.upsert_rule(
Rule(name="baseline", kind="domain", match="baseline.example.com", target="tun0"),
actor="system",
)
transaction_open = threading.Event()
reader_started = threading.Event()
release = threading.Event()
observed: list[list[str]] = []
writer_errors: list[str] = []
def writer() -> None:
try:
with db.transaction() as conn:
conn.execute(
RULE_INSERT,
(
"uncommitted",
"domain",
"uncommitted.example.com",
"tun0",
100,
1,
"2026-01-01T00:00:00Z",
"2026-01-01T00:00:00Z",
),
)
transaction_open.set()
release.wait(JOIN_TIMEOUT)
except BaseException as exc: # noqa: BLE001 - фиксируем падение писателя
writer_errors.append(f"{type(exc).__name__}: {exc}")
def reader() -> None:
reader_started.set()
observed.append([item.name for item in store.list_rules()])
writer_thread = threading.Thread(target=writer)
writer_thread.start()
assert transaction_open.wait(JOIN_TIMEOUT)
reader_thread = threading.Thread(target=reader)
reader_thread.start()
assert reader_started.wait(JOIN_TIMEOUT)
time.sleep(OBSERVE_SECONDS)
# незафиксированная строка наружу не видна: чтение ждёт завершения транзакции
assert observed == []
release.set()
writer_thread.join(JOIN_TIMEOUT)
reader_thread.join(JOIN_TIMEOUT)
assert writer_errors == []
assert observed == [["baseline", "uncommitted"]]
# -- #54 п.4: во время падающего apply наружу не отдаётся откаченное состояние ----
class BlockingApplier:
"""Применитель, который «висит» внутри apply и затем падает как iptables."""
def __init__(self) -> None:
self.entered = threading.Event()
self.release = threading.Event()
self.reverted: list[dict[str, Any]] = []
def apply(self, payload: Mapping[str, Any]) -> None:
self.entered.set()
assert self.release.wait(JOIN_TIMEOUT)
raise RuntimeError("mock iptables: exit 4")
def revert(self, payload: Mapping[str, Any]) -> None:
self.reverted.append(dict(payload))
def test_read_during_failing_apply_never_sees_discarded_state(tmp_path: Path) -> None:
"""AC #20: снаружи не видно «полуприменённой» конфигурации, которую затем откатят."""
db, store = open_store(tmp_path)
applier = BlockingApplier()
engine = ApplyEngine(store, applier, actor="ui:admin")
with db:
store.upsert_rule(
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
actor="system",
)
before = capture_state(db.connection)
plan = engine.stage(
DesiredState(
mode=Mode.FULL,
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
),
reason="сломается",
)
errors: list[BaseException] = []
observed: list[list[str]] = []
reader_started = threading.Event()
def apply_worker() -> None:
try:
engine.apply(plan)
except BaseException as exc: # noqa: BLE001 - ожидаем ApplyError
errors.append(exc)
def reader() -> None:
reader_started.set()
observed.append([item.name for item in store.list_rules()])
apply_thread = threading.Thread(target=apply_worker)
apply_thread.start()
assert applier.entered.wait(JOIN_TIMEOUT)
reader_thread = threading.Thread(target=reader)
reader_thread.start()
assert reader_started.wait(JOIN_TIMEOUT)
time.sleep(OBSERVE_SECONDS)
assert observed == [], "чтение вернуло состояние, которого ещё нет в БД"
applier.release.set()
apply_thread.join(JOIN_TIMEOUT)
reader_thread.join(JOIN_TIMEOUT)
assert len(errors) == 1
error = errors[0]
assert isinstance(error, ApplyError)
assert error.restored is True
assert observed == [["keep"]]
assert [item.name for item in store.list_rules()] == ["keep"]
assert capture_state(db.connection) == before
assert applier.reverted == [before]
# -- #55 D-1: BaseException не должен оставлять staged без исхода ---------------
class InterruptingApplier:
"""Применитель, прерывающий apply исключением из ``BaseException``."""
def __init__(self, exc_type: type[BaseException]) -> None:
self.exc_type = exc_type
self.applied = 0
self.reverted: list[dict[str, Any]] = []
def apply(self, payload: Mapping[str, Any]) -> None:
self.applied += 1
raise self.exc_type("внешний применитель прерван")
def revert(self, payload: Mapping[str, Any]) -> None:
self.reverted.append(dict(payload))
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit, asyncio.CancelledError])
def test_base_exception_from_applier_reverts_and_records_failure(
tmp_path: Path, exc_type: type[BaseException]
) -> None:
"""Отмена/прерывание apply → revert внешней подсистемы + failed в audit_log."""
db, store = open_store(tmp_path)
applier = InterruptingApplier(exc_type)
engine = ApplyEngine(store, applier, actor="ui:admin")
with db:
store.upsert_rule(
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
actor="system",
)
before = capture_state(db.connection)
plan = engine.stage(
DesiredState(
mode=Mode.FULL,
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
),
reason="отмена",
)
with pytest.raises(exc_type):
engine.apply(plan)
assert applier.applied == 1
assert applier.reverted == [before]
assert capture_state(db.connection) == before
assert [item.name for item in store.list_rules()] == ["keep"]
entries = store.list_audit(entity=Entity.APPLY.value, entity_id=plan.plan_id, limit=10)
assert entries[0].status == AUDIT_STATUS_FAILED
assert exc_type.__name__ in (entries[0].detail or "")
assert entries[0].new_value == before
# -- #55 D-2: конкурентные set_geoip_schedule дают связный журнал ---------------
def test_concurrent_geoip_updates_keep_audit_chain_consistent(tmp_path: Path) -> None:
"""Каждое изменение пишет корректный old_value: цепочка old→new не рвётся."""
db, store = open_store(tmp_path)
with db:
initial = store.get_geoip_schedule()
crons = ("0 1 * * *", "0 2 * * *")
errors: list[str] = []
barrier = threading.Barrier(len(crons))
def worker(cron: str) -> None:
try:
barrier.wait(timeout=JOIN_TIMEOUT)
store.set_geoip_schedule(cron=cron, actor=f"ui:{cron}")
except BaseException as exc: # noqa: BLE001 - фиксируем падение потока
errors.append(f"{cron}: {type(exc).__name__}: {exc}")
run_threads([lambda cron=cron: worker(cron) for cron in crons])
assert errors == []
entries = list(reversed(store.list_audit(entity=Entity.GEOIP_SCHEDULE.value, limit=10)))
assert len(entries) == len(crons)
first, second = entries
assert first.old_value["cron"] == initial.cron
assert first.new_value["cron"] == second.old_value["cron"]
assert second.new_value["cron"] == store.get_geoip_schedule().cron
assert {first.new_value["cron"], second.new_value["cron"]} == set(crons)