BUG (#20/PR #50): Store — незащищённые чтения на общем соединении: TypeError/IndexError и видимость откаченного состояния #54
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Найдено на ревью PR #50 (ветка
feat/20-storage, head7e10ffaf8ad436f10b4195fddc4120ce576b2c7d). Автотесты PR проходят (98 passed, coverage 97.94 %), но независимая проверка воспроизводит дефект конкурентного доступа.Severity: critical (крах чтений + выдача состояния, которое было откачено).
Refs #20, #50.
Что не так
Database._lockберётся только вtransaction()/migrate()/close(). Все чтенияStore(list_rules,get_mode,get_rule,get_rule_by_name,list_exceptions,list_configs,list_devices,list_audit,counts) исполняются через общийself._db.connectionбез блокировки, приcheck_same_thread=False. Одинsqlite3.Connectionна несколько потоков без сериализации читателей → перекрёстные результаты операторов и грязные чтения.sqlite3.threadsafety=3защищает от порчи памяти, но не даёт изоляции.Воспроизведение 1 — крах чтения и потеря мутаций
Фактический вывод (на ветке PR #50):
Повтор серии 5×: 3/5 прогонов упали (потеряно 4–38 мутаций на прогон); в другой серии наблюдался
IndexError: tuple index out of range. Контроль: та же нагрузка без чтений — 5/5 прогоновrules=100/100,audit=100/100, ошибок 0 (путь записи сериализован корректно).Воспроизведение 2 — грязное чтение (детерминированно)
Пока поток A держит открытую транзакцию (
BEGIN IMMEDIATE), поток B на том же соединении читает черезstore.list_rules():Воспроизведение 3 — нарушение AC «не частично применённая конфигурация»
Внешний применитель блокируется внутри
apply(БД уже изменена), в это время читатель получает «новое» состояние; затем применитель падает и состояние откатывается:То есть API в окне применения отвечает конфигурацией, которая не была применена и была откачена.
Ожидаемое
GET /rules,GET /mode, ...) не падают и не зависят от конкурентных операторов на том же соединении.applyнаружу не видно состояние, которое будет откачено.Возможные направления правки (на выбор владельца)
RLock(просто; тогда честно задокументировать «одно соединение, все операции под локом»).threading.local/пул) + WAL: читатели реально не блокируют писателя.check_same_thread=False, задокументировать контракт).Область проверки для повторного ревью
rules == audit_rows == N*M.Воспроизведено скриптами
tools/probe_conc.py,tools/probe_conc2.py,tools/probe_qa.py(H2) в воркспейсе задачи t_2dec61bb; полный отчёт —qa_report_pr50.md, приложен к карточке.Исправлено в
feat/20-storage@c4661c9db97ce4ed2c2c3471af06b18b69d99162Выбран вариант 1 из описания: сериализация чтений тем же
RLock.Правка
db.py: новыйDatabase.read()— contextmanager, берётself._lock(тот жеRLock, чтоtransaction()), поэтому чтение ждёт завершения чужой транзакции и не видит незафиксированных строк.journal_mode,schema_version,verify_schema()тоже под локом.connectionостался, но в docstring явно: «только для однопоточного кода».repository.py: все чтенияStore(get_mode,get_geoip_schedule,list_rules,get_rule,get_rule_by_name,list_exceptions,get_exception,list_configs,get_config,list_devices,get_device,list_audit) переведены наwith self._db.read() as conn:.counts()читает все таблицы в одномread()— счётчики согласованы.raw()в docstring помечен как однопоточный.store/README.md: раздел «Потокобезопасность (#54)» — таблица контракта (мутация / чтение / прямой доступ) и следствия для API-слоя (#19/#21), включая «во время apply наружу не видно откатываемое состояние».WAL остался включённым (устойчивость к падению процесса, внешние читатели); внутрипроцессного параллелизма чтений контракт не обещает — при необходимости переходим на соединение-на-поток отдельным решением.
Воспроизведение и проверка
7e10ffafновыми тестами (падают) и закрыты наc4661c9d(проходят):Тесты (
backend/tests/test_store_concurrency.py):test_concurrent_upserts_and_reads_are_consistent— 4 потока × 25 upsert + чтения →errors == [],rules == audit == 100;test_reads_never_see_uncommitted_rows— пока транзакция открыта, читатель блокирован; после коммита видит['baseline', 'uncommitted'];test_read_during_failing_apply_never_sees_discarded_state— читатель во время падающего apply ничего не видит до отката; после —['keep'].Ваши пробы из ревью, прогнанные против ветки:
Гейт: ruff check/format чисто, mypy strict чисто (18 файлов PR-only / 21 с файлами #47), pytest 105 passed на PR-only дереве (coverage 97.79 %), 107 passed с файлами #47 (97.80 %).
QA (ревью раунд 2, PR #50 @
c4661c9d): дефект воспроизведён и закрыт.Независимая проверка (не тесты автора):
Database.read()к «без лока», новый тест падает (TypeError: int() argument must be ... not 'NoneType',InterfaceError: bad parameter or other API misuse); наc4661c9d— 10/10 прогонов концуренси-файла зелёные.rules == audit == 180, ошибок 0.['keep'], после отката —['keep'](AC3 не нарушен).store/db.py(стр. 12-26, 152-162) иstore/README.md§ «Потокобезопасность (#54)».Merge — после влития #17 (порядок #17 → #50). Issue закрывается владельцем после merge.
Закрыто: исправлено в PR #50 (head
c4661c9d), влито вmain. ЧтенияStoreпереведены под общийRLock(Database.read()), контракт потокобезопасности задокументирован вstore/db.pyиstore/README.md. Регрессии:test_concurrent_upserts_and_reads_are_consistent,test_reads_never_see_uncommitted_rows,test_read_during_failing_apply_never_sees_discarded_state. Независимая проверка ревьюера: 6 потоков × 30 upsert × 5 прогонов →rules == audit == 180, 0 ошибок, грязных чтений нет; падающий apply наружу отдаёт только прежнее состояние. Мутационный откат фикса валит новые тесты (не тавтологии).