Закрывает замечания ревью 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
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).