Files
awg-dash/backend/awg_api/store/README.md
embeddevio 249f32e3f9 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
2026-10-28 21:12:09 +03:00

5.8 KiB

store — хранилище состояния (issue #20)

SQLite (WAL) в /data/awg-dash.db: правила маршрутизации, исключения, режим, расписание GeoIP, метаданные конфигов, устройства, журнал аудита и снимки состояния. Путь переопределяется аргументом или переменной AWG_DASH_DB.

Модуль Назначение
db.py открытие соединения, PRAGMA (WAL, synchronous=NORMAL, foreign_keys, busy_timeout), транзакции BEGIN IMMEDIATE
migrations.py версионированные миграции, schema_migrations, проверка схемы при старте (verify_schema)
models.py dataclass-модели сущностей и перечисления (Mode, Entity, Rule, …)
validation.py чистые функции валидации (domain/CIDR/ipset, MAC, cron, режим, политика)
repository.py Store: чтения + аудируемые мутации «кто/когда/что/old→new»
audit.py запись и чтение audit_log (мутация и её аудит — одна транзакция)
state.py снимки состояния (state_snapshots), capture_state, restore_state, rollback_to_snapshot
apply.py ApplyEngine: validate → stage → apply → rollback и протокол Applier (awg/iptables)
service.py open_storage() — сборка Database + Store + ApplyEngine для старта приложения
errors.py StorageError, SchemaError, ValidationError, ApplyError(restored=…)

Гарантии

  • Пустой /data — каталог создаётся, миграции применяются, схема v1 появляется без ручных действий.
  • Версия схемы — при старте схема проверяется: новее ожидаемой или не мигрированная → SchemaError (без «автоотката»).
  • Аудит — каждая мутация пишет actor/ts/action/entity/old_value/new_value в audit_log в той же транзакции; old_value читается внутри той же транзакции, поэтому конкурентные обновления не рвут цепочку old→new.
  • Атомарность — применяемое состояние проходит validate → stage → apply; падение внешнего применителя откатывает транзакцию БД и вызывает Applier.revert(previous), ApplyError.restored=True. Ловится BaseException, поэтому отмена (asyncio.CancelledError), KeyboardInterrupt и SystemExit тоже дают revert и запись failed в журнале.
  • Откат — ApplyEngine.rollback(plan_id) возвращает состояние из снимка.

Потокобезопасность (#54)

Контракт: одно соединение на процесс, все операции под одним RLock.

Операция API Поведение
Мутация Database.transaction() BEGIN IMMEDIATE … COMMIT/ROLLBACK под локом
Чтение Database.read() берёт тот же лок; ждёт завершения чужой транзакции
Прямой доступ Database.connection / Store.raw() лок не берётся — только однопоточный код (миграции, тесты, отладка)

Следствия, на которые можно опираться в API-слое (#19/#21):

  • обычные чтения (GET /rules, GET /mode, …) не падают от конкурентных мутаций и не отдают перемешанный результат операторов;
  • читатель не видит незафиксированных строк другой транзакции;
  • во время apply наружу не видно состояние, которое затем будет откачено (AC «не частично применённая конфигурация»);
  • конкурентные запросы ждут, а не получают ошибку; потокобезопасность подтверждена backend/tests/test_store_concurrency.py.

WAL остаётся включённым: он даёт устойчивость к падению процесса и позволяет читать БД снаружи (sqlite3 CLI) во время работы шлюза. Внутрипроцессного параллелизма чтений контракт не обещает: если позже понадобится параллельная пропускная способность, меняем контракт на «соединение на поток» (threading.local), а не полагаемся на неявное поведение.

Пример

from awg_api.store import DesiredState, Rule, open_storage

with open_storage() as storage:  # /data/awg-dash.db
    storage.store.set_geoip_schedule(cron="0 3 * * *", actor="ui:admin")
    plan = storage.engine.stage(
        DesiredState(
            mode=storage.store.get_mode(), rules=(Rule("yt", "domain", "youtube.com", "tun0"),)
        ),
        reason="ui: split-tunnel для YouTube",
    )
    result = storage.engine.apply(plan)

Тесты

backend/tests/test_store_*.py — миграции, аудит, репозиторий, apply/rollback, персистентность после рестарта. Порог покрытия — 70 % (pyproject.toml).