"""Тесты потокобезопасности слоя хранения (#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)