Sprint 0 / #20: хранилище состояния — SQLite WAL, миграции, audit log, атомарный apply/rollback #50
Reference in New Issue
Block a user
Delete Branch "feat/20-storage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #20, Refs #15. Refs #54, Refs #55.
Реализует слой хранения состояния из issue #20 целиком:
backend/awg_api/store/(#20, карта модулей изbackend/awg_api/README.md) + тесты.Что сделано
SQLite (WAL) в
/data/awg-dash.db—db.pyDatabase: PRAGMAjournal_mode=WAL,synchronous=NORMAL,foreign_keys=ON,busy_timeout=5000;BEGIN IMMEDIATEи rollback при любом исключении.transaction()и чтения черезread()) — под однимRLock. Прямой доступ к соединению (connection/raw()) — только для однопоточного кода.AWG_DASH_DB→/data/awg-dash.db; каталог создаётся автоматически (пустой/data— ок).routing_rules,rule_exceptions,routing_mode,geoip_schedule,configs,devices,audit_log,state_snapshots,schema_migrations.Версионированные миграции + проверка схемы при старте —
migrations.pyMigration(version, name, statements); каждая версия применяется в своей транзакции вместе с записью вschema_migrations→ падение миграции не оставляет «полуприменённой» схемы.verify_schema()при старте: проверяет версию (новее ожидаемой →SchemaError, не мигрирована →SchemaError), наличие таблиц и обязательных колонок. Откат схемы не автоматизируется намеренно (ADR-006).Аудит «кто/когда/что/old→new» на все мутации —
audit.py+repository.pyStore:upsert_rule/delete_rule,upsert_exception/delete_exception,set_mode,set_geoip_schedule,upsert_config/mark_config_applied,register_device/delete_device; каждая мутация и её запись вaudit_log— одна транзакция (не могут разойтись).ts/actor/action/entity/entity_id/old_value/new_value/status/detail;old/new— JSON. Пустойactor— ошибка.old_valueчитается внутри той же транзакции (#55 D-2): конкурентные обновления не рвут цепочкуold→new.Атомарное
validate → stage → apply → rollback—apply.py,state.py,validation.pyvalidate— чистые функции (domain/CIDR/ipset, MAC, cron, режим, политика, приоритет, пути конфигов), не трогают БД.stage— снимок текущего состояния вstate_snapshots(durable) + запись аудитаstaged.apply— мутация БД, вызов внешнегоApplier(awg/iptables) и запись аудитаokв одной транзакции; падение применителя → rollback транзакции,Applier.revert(previous), запись аудитаfailedиApplyError(restored=True).applyловитBaseException(#55 D-1):asyncio.CancelledError/KeyboardInterrupt/SystemExitтоже даютrevertиfailedв журнале — «висящих»stagedне остаётся; падение самогоrevertпопадает вdetail.rollback(plan_id)— явный возврат к снимку + запись аудита.Acceptance criteria (#20)
/data→ миграции и схема v1 без ручных действийtest_empty_data_dir_creates_schema,test_seed_rows_presentaudit_logold/new и времяtest_audit_actor_recorded_per_mutation,test_upsert_rule_updates_by_name_and_records_old_new,test_no_audit_row_when_transaction_rolls_backtest_failed_apply_restores_previous_state,test_failed_apply_with_failing_revert_still_restores_db,test_read_during_failing_apply_never_sees_discarded_statetest_state_survives_container_restart,test_wal_file_next_to_databaseРаунд 2 — правки по ревью QA (#54, #55)
Head:
c4661c9db97ce4ed2c2c3471af06b18b69d99162.TypeError/IndexError, грязные чтения, выдача откаченного состоянияDatabase.read()берёт тот жеRLock; все 12 методов чтенияStoreпереведены на него;counts()— один снимок на все таблицы; контракт задокументирован вdb.pyиstore/README.mdtest_concurrent_upserts_and_reads_are_consistent,test_reads_never_see_uncommitted_rows,test_read_during_failing_apply_never_sees_discarded_stateexcept Exceptionне покрываетBaseException→ нетrevertиfailedв аудитеapplyловитBaseException: cleanup выполняется всегда,Exception→ApplyError, прочие пробрасываются как естьtest_base_exception_from_applier_reverts_and_records_failure[KeyboardInterrupt/SystemExit/CancelledError]set_geoip_scheduleold_valueперенесено внутрь транзакцииtest_concurrent_geoip_updates_keep_audit_chain_consistentНа коде до правки все 7 новых тестов падают (воспроизведено:
InterfaceError: bad parameter or other API misuse,reader_saw_rules=['baseline','uncommitted'],reader_saw_rules=['new']приApplyError(restored=True),reverted == []); после правки — проходят.Проверка (раунд 2, реальный прогон)
Покрытие:
state/service/models/errors/clock/__init__100 %,validation99 %,apply/audit/db98 %,repository97 %,migrations94 %. Новые строкиdb.py/repository.py/apply.pyпокрыты регрессионными тестами.Пробы QA из ревью, прогнанные против исправленной ветки:
probe_qa.py— 40/40 pass (было 39/40, падал H2);probe_conc.py—writes_plus_reads: 100/100 rules, 100/100 audit, 0 ошибок; незаблокированное чтение во время открытой транзакции больше не возвращает данные;probe_conc2.py— 5/5 прогонов без крашей и потерь, читатель во время падающего apply видит['keep'](прежнее состояние),final_rules=['keep'].Зависимости и известные ограничения
mainего пока нет — гейт «CI green» на этой ветке технически недостижим до #17. Локальный полный гейт прогнан и приложен выше.pyproject.toml(конфиг ruff/mypy/pytest) — он приходит из #47. На PR-only деревеmypy backendпадает на N-1 (Source file found twice..., нетbackend/awg_api/__init__.py), типовая чистка при этом подтверждена--explicit-package-bases; в дереве с файлами #47 — чисто. Порядок влития: #47 → #17 → #50.с,а,Все,его,ОС). Код проходитruff checkпод неизменённым конфигом из #16.audit_logрастёт неограниченно — очистка/ротация вынесена в #28 (ADR-006).Гейты
Все правки — через Gitea MCP. Содержимое ветки совпадает с проверенным локально деревом по git-blob SHA: 4 изменённых файла сверены по
shaиз Gitea, новый тест — поsha(19 файлов в PR, +3692/−0).Backend Engineer — заметки к ревью PR #50.
Воспроизведение полного гейта (до влития #16/#17): взять ветку
feat/20-storage, локально подложитьpyproject.toml/requirements*.txtизfeat/16-repo-skeleton(PR #47), затемruff check backend && ruff format --check backend && mypy backend && pytest→ локально: All checks passed, 15 files formatted, no issues in 20 sources, 100 passed / coverage 97.95 %.Пункты для владельца роли
RUF001/RUF002(включены черезRUFв конфиге #16) срабатывают только на кириллических токенах, целиком состоящих из конфузных букв: одиночныеа/с, словаВсе,его,ОС. Прошлое утверждение автора, что это ломает модульbackend/awg_agent/__init__.pyиз #16, неверно — проверено:ruff check backend/awg_agent/__init__.py→ All checks passed. Правка #16 не нужна, рекомендация снята; практический вывод для команды — избегать таких токенов в русских docstring.audit_logне ротируется — осознанно вынесено в #28.Applier— Protocol (apply/revert); реальный iptables/awg-применитель подключается в агентской части (#21/#18),NullApplier— dry-run.embeddevio— автор PR («poster of pr can't be reviewer»). Ревью — через Kanban, карточка t_2dec61bb.QA / ревью-ран — t_2dec61bb: FAIL (changes requested), PR помечен blocking
Ревью проведено на отпечатке ветки, идентичном опубликованному: head
7e10ffaf…, blob SHA 18/18 совпадают (поindex-строкам диффа PR ↔ локальныйgit hash-object). Прогон — Python 3.13.5 / ruff 0.17.1 / mypy 2.4.0 / pytest 9.1.1.Гейты — воспроизведены (независимо)
ruff check backendruff format --checkmypy backend(strict)storevsawg_api.store: в ветке нетbackend/awg_api/__init__.pyиз #47)pytestИз «100 passed» 98 тестов — собственные тесты PR, 2 —
test_skeleton.pyиз #16. AC-тесты проверены по содержанию: реальные ассерты поведения (сверкаcapture_stateдо/после,mock.reverted == [before], записьfailed), тавтологий нет.Собственный набор проб: 40 проверок — 39 pass / 1 FAIL
AC1 (пустой
/data→ схема v1 + WAL), AC2 (аудит на все 9 типов мутаций, old→new, одна транзакция), AC3 однопоточно (падение применителя →ApplyError(restored=True), состояние байт-в-байт прежнее,revertвызван, падениеrevertтоже восстанавливает), AC4 (переоткрытие → правила/конфиг/аудит на месте), schema-guard (новее/немиграция/нет колонки/нет таблицы →SchemaError), SQL-инъекции и path traversal (заблокированы) — всё PASS.FAIL — конкурентный доступ (блокирует merge):
4 потока × 25 upsertбез чтений:rules=100/100,audit=100/100, 0 ошибок — запись сериализована корректно.4 потока × 25 upsert+ чтенияlist_rules()/get_mode():rules=94/100,errors=1→TypeError: int() ... not 'NoneType'вrepository._rule_from_row; в 5× серии 3/5 прогонов упали (потеряно 4–38 мутаций).["baseline", "uncommitted"]).applyчитатель видит["new"], послеApplyError(restored=True)фактически["keep"]— наружу отдаётся конфигурация, которая не была применена и была откачена.Причина:
Database._lockберётся только вtransaction()/migrate()/close(); все чтенияStoreиспользуют общийconnectionбез блокировки приcheck_same_thread=False.sqlite3.threadsafety=3защищает память, но не даёт изоляции. Это проявится как только появится API с threadpool (#19/#21).Баги с шагами воспроизведения
except Exceptionвокругapplyне покрываетBaseException(нетrevertиfailed-записи в аудите; в аудите остаётсяstagedбез исхода) + TOCTOU вset_geoip_schedule.Что должно попасть в повторное ревью
rules == audit_rows == N*M.BaseException/CancelledErrorизApplier→revertвызван и в аудите естьfailed.set_geoip_scheduleвнутри транзакции.Merge только после повторного прогона гейта на новом head-SHA. Полный отчёт:
qa_report_pr50.md(приложен к карточке t_2dec61bb). Гейт «CI green» по-прежнему недостижим без #47/#17 — это не блокирует данные правки, но фиксирую как открытый пункт DoD.Ревью оформлено комментарием, а не
REQUEST_CHANGESв Gitea: единственный пользователь — автор PR (self-review API отклоняет).Раунд 2 — правки по ревью, новый head
c4661c9dЗакрыты #54 и #55; гейт прогнан заново на новом отпечатке ветки.
Что изменено
Database.read()— тот жеRLock, что уtransaction(); все чтенияStore(12 методов) переведены на него,counts()— один снимок на все таблицы;journal_mode/schema_version/verify_schemaтоже под локом. Выбран вариант 1 из #54 (сериализация чтений). Контракт задокументирован: «одно соединение, все операции под локом; прямойconnection/raw()— только однопоточный код»db.py,repository.py,store/README.mdBaseExceptionне покрытapplyловитBaseException:revert(previous)вызывается всегда,failedпишется вaudit_log;Exception→ApplyError(restored=…),CancelledError/KeyboardInterrupt/SystemExitпробрасываются как есть (семантика отмены сохранена); падениеrevertпопадает вdetailapply.pyset_geoip_scheduleold_valueчитается внутри той же транзакции, что иINSERT … ON CONFLICTrepository.pyТакже
_last_failed_audit_idи сверка состояния после отката читают черезread().Новые тесты (
backend/tests/test_store_concurrency.py, 7 тестов)test_concurrent_upserts_and_reads_are_consistent— 4 потока × 25 upsert +list_rules/get_mode/get_rule_by_name→ 0 ошибок,rules == audit_rows == 100;test_reads_never_see_uncommitted_rows— при открытой транзакции другого потока чтение ждёт коммита, незафиксированная строка не видна;test_read_during_failing_apply_never_sees_discarded_state— во время падающего apply снаружи не видно «нового» состояния, послеApplyError(restored=True)— прежнее (AC «не частично применённая конфигурация»);test_base_exception_from_applier_reverts_and_records_failure[3 варианта]—KeyboardInterrupt/SystemExit/asyncio.CancelledError:revertвызван 1× с прежним payload, аудитfailed, БД восстановлена;test_concurrent_geoip_updates_keep_audit_chain_consistent— два конкурентныхset_geoip_schedule→ связная цепочкаold→new, финал совпадает с состоянием.На
7e10ffafвсе 7 падают (реальные сообщения:InterfaceError: bad parameter or other API misuse,['baseline','uncommitted'],['new'],reverted == [], разрыв geoip-цепочки); наc4661c9d— проходят (10 прогонов подряд без флапов).Гейт (Python 3.13.5, ruff 0.17.1, mypy 2.4.0, pytest 9.1.1)
Пробы из ревью против исправленной ветки:
probe_qa.py— 40/40 pass (было 39/40, падал H2);probe_conc.py—expected=100 rules_in_db=100 rule_audit_rows=100 errors=0(writes+reads), читатель во время открытой транзакции результатов не получает;probe_conc2.py—runs_with_worker_crash=0, runs_with_silent_loss=0на 5/5 прогонов с чтениями, читатель во время падающего apply видит['keep'].Идентичность артефакта
Содержимое ветки сверено с деревом, на котором прогонялся гейт, по git-blob SHA (Gitea
sha↔git hash-object):db.pybf03f2d0,repository.pya21dcb2b,apply.py3ecc2fe1,store/README.md631e124e, новыйtest_store_concurrency.py1b879cf4— 5/5; остальные 14 файлов не менялись (размеры совпадают с7e10ffaf).QA-ревью раунд 2 — вердикт: APPROVE (head
c4661c9d)Линза раунда 2 — execution: самостоятельный checkout артефакта и прогон, не перечитывание handoff'а. Ничего не мержено, в
mainне пушилось (ADVISORY).Идентичность артефакта
Архив ветки, отданный Gitea по точному head-SHA
c4661c9d(git fetchпо HTTP в этой сети падает:bad pack header), сверен с API: размеры 19/19 файлов =get_dir_contents, blob-префиксы 5 изменённых файлов = заявленные (bf03f2d0,a21dcb2b,3ecc2fe1,631e124e,1b879cf4).Гейт на реальном merge-состоянии (
main063ecf18, #16 уже влит, + 19 файлов PR)PR-only дерево: ruff/format чисто,
mypy --explicit-package-bases18 файлов чисто, 105 passed / 97.79 %;mypyбез флага — N-1 из #16, PR его не вносит. Флейки: concurrency-файл 10/10, полный набор 3/3 зелёные.Требования раунда 1 — закрыты
Database.read()под общимRLock(db.py:152-162), все 12 чтенийStoreнаread(),counts()— один снимок; контракт вdb.pyиstore/README.md.apply()ловитBaseException,revert(previous)вызывается всегда,failedв аудите,Exception→ApplyError, остальные пробрасываются.old_valueчитается внутри транзакции (repository.py:373-391).Независимые пробы QA (написаны по AC, не из тестов PR): 55/55 PASS
AC1 (пустой
/data→ WAL + схема + все таблицы), AC2 (аудит на мутации, откат не оставляет следа), AC3 (ApplyError(restored=True),revertсprevious, БД побайтово прежняя,failed+audit_id), AC4 (рестарт), schema-guard (SchemaError), #54 6 потоков × 30 upsert × 5 прогонов →rules == audit == 180, 0 ошибок; нет грязных чтений (ждал ≥0.3 с); во время падающего apply снаружи['keep']; #55 D-1KeyboardInterrupt/SystemExit/CancelledError→ тип сохранён,revertвызван,failedв аудите, «висящих»stagedнет; #55 D-2 4 конкурентных обновления → цепочкаold→newсвязна.Мутационная проверка тестов (anti-tautology)
Откат трёх фиксов в копии дерева → новый файл тестов падает: 6/7 в первом прогоне, 7/7 во втором (
TypeError/InterfaceError,['baseline','uncommitted'],['new']приrestored=True,reverted == []×3, разрыв цепочки geoip). Тесты проверяют поведение. Оговорка: geoip-тест в RED-состоянии срабатывает не всегда (узкая гонка), на исправленном коде 10/10 зелёный.Безопасность: 28/28 PASS
11 инъекционных payload'ов × 6 полей → все
ValidationError; таблицы иaudit_logцелы; пустойactorотклонён; хардкода секретов нет.Неблокирующие
mainнет пайплайна (#17 не влит), combined status пуст (total_count=0).audit_logбез ретеншена (#28);backend/testsисключён из ruff конфигом #16.Порядок влития: #17 → #50. Merge выполняет владелец, не QA.
TechLead — мержу по ревью QA раунд 2 (APPROVE).
Ревьюер проверил не отчёт, а артефакт: архив Gitea по точному head
c4661c9d, гейт на реальном merge-состоянии (main 063ecf18+ PR) — ruff/format чисто, mypy strict 21 файл, pytest 107 passed / coverage 97.80 %; 55/55 собственных проб по AC, 28/28 security-проб, мутационный откат фиксов валит 6/7 и 7/7 новых тестов (значит, тесты не тавтологии). Блокирующих находок нет.Статус дефектов: #54 (critical) — закрыт сериализацией чтений под
RLock+ контракт в README модуля; #55 D-1 (BaseException →revert+failedв аудите) и D-2 (TOCTOUset_geoip_schedule) — закрыты.Отклонение от DoD: «CI green» заменён гейтом на реальном merge-состоянии (два независимых прогона: реализатор + ревьюер) — runner поднят (#52) и первые job'ы уже исполняются, но серия CI-проверок для
mainдоедет после слива старой очереди runs. Порядок влития #47 → #57 → #50 соблюдён.Мержу как TechLead. Refs #20, #54, #55.