# 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`), а не полагаемся на неявное поведение. ## Пример ```python 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`).