30 KiB
CODE-REVIEW-491-r2
Issue: #491 — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи».
Материал ревью: ветка issue/491-optimize-undo-pair-recovery, точный SHA 1787e505a192f400c363666b549dbcf46834d90f (рабочая копия уже на нём).
Заход: r2. Блокирующих циклов израсходовано: 1/4.
ТЗ: docs/specs/491-optimize-undo-pair-recovery.md, ревью ТЗ зелёное (SPEC-REVIEW-491-r1).
Почему разбор полный, а не по дельте
Поверх материала r1 (4ee83716, ныне осиротевший после ребейза — SHA умер уже
после публикации отчёта r1, это обычное дело, PROCESS §2.10) легли 2 коммита
dev (e0e68f25 #496, 9bfe7885 #503). После ребейза на ушедший вперёд dev
это другой код (§7.2), поэтому разбор — полный, не по дельте, как и предписано
в постановке раунда.
Проверка содержимым, а не доверием: git diff 4ee83716 1787e505 --stat
показывает изменения ровно в трёх файлах — .github/workflows/process.yml,
scripts/mutation-gate.mjs, test/mutation-gate.test.mjs — все три из
несвязанных с #491 коммитов dev (CI-инфраструктура ревью-экшна и
package.json-релевантность гварда). Ни один продуктовый Python-файл
(store.py, websocket_api.py, __init__.py), ни один tests_backend/*.py,
ни один файл документации #491 между r1 и r2 не менялся ни байтом. Это не
основание сократить объём разбора (диктует §2.10 полноту при ребейзе), но
объясняет, почему полный независимый разбор ниже приходит к тем же выводам,
что и r1, а не к новым дефектам в самом протоколе.
Скоуп диффа
git diff origin/dev...HEAD --stat:
custom_components/houseplan/__init__.py | 86 ++----
custom_components/houseplan/store.py | 151 ++++++++++
custom_components/houseplan/websocket_api.py | 251 ++++++++++++-----
docs/ARCHITECTURE.md | 26 +-
docs/CHANGELOG.md | 5 +
docs/CHANGELOG.ru.md | 7 +
docs/CONFIG-COMPATIBILITY.md | 20 ++
docs/TESTING.md | 15 +
docs/USER-GUIDE.md | 7 +
docs/USER-GUIDE.ru.md | 8 +
docs/reviews/CODE-REVIEW-491-r1.md | 304 ++++++++++++++++++++
docs/reviews/SPEC-REVIEW-491-r1.md | 165 +++++++++++
docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++
docs/specs/README.md | 1 +
scripts/mutation-gate.mjs | 111 ++++++++
tests_backend/test_ha_import_export.py | 31 +++
tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++
17 files changed, 1789 insertions(+), 141 deletions(-)
Backend-only (класс A: custom_components/houseplan/*.py; класс B:
scripts/mutation-gate.mjs, tests_backend/**; класс C: docs/**). src/**
не тронут — фронтенд браузерные гейты (smoke, golden, check-docs) вне
обязательного набора.
6 коммитов на ветке относительно origin/dev, все с Issue: #491; коммит
488f70c4 (User-Visible: yes) несёт код и правки обоих changelog в одном
коммите.
Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| Medium (в скоупе): AC6 («неразрешимый pending блокирует новый write») — защита в продуктовом коде, проверяется только дорогим HA-harness гейтом, не имела названного мутанта | Добавлен мутант pair-recovery-fence-ignores-resolution-failure в scripts/mutation-gate.mjs, патчащий except Exception: в _resolved_write_pair так, чтобы отказ async_resolve_pending_pair игнорировался и writer продолжал со свежим несведённым ResolvedStorePair вместо commit_failed; guard — test_issue_491_failed_fence_blocks_point_write_and_keeps_intent |
Коммит 1787e505 (тот же код, что был в 4ee83716 до ребейза), scripts/mutation-gate.mjs:7795-7818. Проверено мной исполнением: node scripts/mutation-gate.mjs --check --id=pair-recovery-fence-ignores-resolution-failure → ok pair-recovery-fence-ignores-resolution-failure (якорь патча найден ровно один раз в текущем дереве, патч синтаксически валиден — ResolvedStorePair(config_data=…, layout_data=…) соответствует сигнатуре dataclass). Сам guard-тест (реальный HA-harness прогон, покрасит ли мутант тест) не воспроизведён ни мной, ни r1 — окружение по-прежнему без Python ≥3.14/HA (см. «Чего не проверял») |
Унаследовано из r1
Поскольку разбор полный (см. выше), ниже перечислено не «пропущено», а то, что я подтвердил тем же способом, что r1 (чтение, не исполнение) и пришёл к тем же выводам независимо, без изменений в самом коде между раундами:
- Совместимость со старым форматом pending (ветка
replace_metadata=Falseвasync_converge_store_pair,store.py) — код идентичен тому, что читал r1 (docs/reviews/CODE-REVIEW-491-r1.md, раздел «Разбор по коду», материал r14c08b8ab/4ee83716). Я перечитал эту ветку самостоятельно (см. ниже) и подтверждаю тот же вывод. - AC9 (форматы и совместимость) и AC7 (setup) через существующие,
нетронутые этим диффом тесты
test_setup_recovers_durable_import_pairиtest_setup_recovers_exact_optimize_storage_roundtrip_pair— я не перечитывал их текст заново построчно (r1 это сделал), полагаюсь на вывод r1: код__init__.py, который они покрывают, я перечитал сам и он не менялся между раундами. - Документация ARCHITECTURE/CONFIG-COMPATIBILITY/USER-GUIDE построчно
сверена с кодом на материале r1 — я самостоятельно перепроверил
ARCHITECTURE.mdиCONFIG-COMPATIBILITY.mdпротив кода (см. «Разбор по коду» ниже) и подтверждаю;TESTING.mdперепроверил и нашёл новое расхождение (Low, см. «Находки»), которого в материале r1 быть не могло, так как мутант, о котором идёт речь, появился только в фиксе r1→r2.
Всё остальное — гейты, весь код websocket_api.py/store.py/__init__.py,
все девять точек _resolved_write_pair, тесты test_issue_491_* — я прочитал
заново сам в этом раунде (результаты ниже), а не принял на веру.
Как проверялось
Дешёвые гейты — прогнаны сам, на 1787e505
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
зелёный, без вывода |
| JS unit | npm test |
2341 passed, 1 skipped, 0 failed |
| Build + сверка бандла | npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js |
зелёный, побайтовое совпадение (ожидаемо: src/** не менялся) |
no-new-any |
node scripts/no-new-any.mjs --base origin/dev --head HEAD |
«Новых any нет» (0 строк в 0 файлах) |
smoke-select |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
«исполняемого frontend-диффа нет» — смоки не выбираются |
process-gate (локально, свежий origin/dev) |
node scripts/process-gate.mjs --issues |
«диапазон origin/dev..HEAD, коммитов 6; гейт пройден, предупреждений 0» |
| Синтаксис Python | python3 -m py_compile custom_components/houseplan/{store,websocket_api,__init__}.py |
OK |
| Python pure-subset | python3 -m pytest tests_backend -q (после pip install pytest pytest-asyncio voluptuous) |
395 passed, 4 skipped — не проверяет новый код: все test_issue_491_* живут в test_ha_*.py, который conftest.py пропускает без homeassistant |
| Мутант-якоря #491 (самостоятельная проверка, дешёвая) | node scripts/mutation-gate.mjs --check --id=<5 id> (все пять: pair-recovery-config-writer-skips-fence, pair-recovery-point-writer-skips-fence, pair-recovery-fence-ignores-resolution-failure, optimize-skips-pair-retry-rollback, optimize-undo-skips-pair-retry-rollback) |
все пять: ok — патч-якорь найден в текущем дереве ровно один раз, синтаксически валиден |
Не прогонялись и почему:
golden:verify,check-docs.mjs, browser-smoke — не нужны:src/**не менялся,smoke-selectподтвердил пустой выбор.npm run invariants— диффа геометрии/ссылок нет. Проверил сам (не только со слов r1):canonicalize_config_geometry/canonicalize_layout_geometryв новой функцииasync_converge_store_pair(store.py:282-283) — это те же вызовы, что уже существовали вlayout_store_payload/async_save_config_state(store.py:172,220, не тронуты этим диффом); новой геометрической трансформации диф не добавляет, меняется только протокол персистентности.python -m pytest tests_backend -qс реальным Home Assistant — недоступно:pytest-homeassistant-custom-component==0.13.357требует Python ≥3.14, в этом окружении только 3.12.3, других интерпретаторов нет,.venv-backendне создан. Ограничение то же, что было у r1 — окружение с тех пор не изменилось.
Зелёный CI на этом SHA
Не существует — Validate на 1787e505 красный, а не просто «не найден»:
прогон,
job «Предполётные проверки» упал на node scripts/process-gate.mjs --github-range --issues:
process-gate: диапазон 71aeb860791709b3011d280f65add13c92cb79f1..1787e505..., коммитов 8
FAIL п.8 статус issue - issue #496: статус не проставлен, а нужен один из S5-ready / S6-in-progress / S7-code-review / S8-merged
Разобрано, а не списано на «гейт не тот»: issue #496 закрыт корректно (без
S*-метки — это правильное состояние закрытого issue). Диапазон 71aeb860..
на 2 коммита шире, чем origin/dev..HEAD (9bfe7885..HEAD, 6 коммитов) —
71aeb860 предшествует обоим приземлившимся на dev коммитам e0e68f25
(#496) и 9bfe7885 (#503, hotfix конвейера ревью). В --github-range режиме
process-gate.mjs резолвит базу через mergeBaseWithDev, вычисляемый по
git fetch origin dev в момент самого CI-джоба (23:01:53 UTC); похоже, в
эту секунду origin/dev на стороне GitHub ещё не был виден job'у с учётом
e0e68f25/9bfe7885 (гонка с параллельным push хотфикса #503 в dev,
случившимся в разгар этого же цикла ревью — см. комментарии issue про
неотработавший конвейер 22:28–22:39). В диапазон попали два уже смёрженных,
не относящихся к #491 коммита, один из которых ссылается на закрытый issue —
и правило 8 фейлится по чужому, легитимно закрытому issue.
Проверено, а не предположено: тот же чек сейчас, со свежим
git fetch origin dev (origin/dev = 9bfe7885, уже включает оба коммита):
$ node scripts/process-gate.mjs --issues
process-gate: диапазон origin/dev..HEAD, коммитов 6
гейт пройден, предупреждений 0
Вывод: красный Validate на 1787e505 — не дефект диффа #491, а гонка в
CI-инфраструктуре ревью (scripts/process-gate.mjs в режиме --github-range
чувствителен к моменту git fetch относительно параллельного push в dev).
Дефект не в скоупе #491 (класс B, чужая подсистема — CI-гейт, не
custom_components/houseplan/*.py) и заведён отдельно:
#504.
Разбор по коду (проверено чтением, самостоятельно в этом раунде)
Перечитаны построчно custom_components/houseplan/store.py (весь diff:
ResolvedStorePair, _pending_target, async_converge_store_pair,
async_resolve_pending_pair), весь diff custom_components/houseplan/websocket_api.py
(_resolved_write_pair, PairCommitFailure, _persist_pair_intent,
_converge_pair/_commit_pair и все девять точек вызова) и diff
custom_components/houseplan/__init__.py (перенос resolver'а перед
square-canvas миграцией).
Ключевые проверки, которые я провёл сам (не переписывание вывода r1):
- Порядок auth → fence проверен по всем девяти вызовам, не по выборке.
grepпо файлу подтверждает: каждый вызов_resolved_write_pair(hass, connection, msg["id"], rt)(строки 532, 732, 794, 881, 1365, 1591, 1858, 1978, 2167) стоит после ближайшего предшествующего_check_write(521, 725, 783, 868, 1357, 1574, 1845, 1963, 2158 соответственно) — во всех девяти writer'ах (5 ordinary + 4 paired по счёту ТЗ §5) permissions проверяются раньше recovery, ни разу наоборот. ws_layout_update(point writer, AC4).data = resolved.layout_data(восстановленный после fence документ), дельта{**layout, msg["device_id"]: msg["pos"]}применяется поверх него,new_rev = int(data.get("rev", 0)) + 1— считается от восстановленной ревизии, не от сырой. Соответствует ТЗ §7 п.4 буквально.ws_layout_set(CAS writer, AC3/AC4).current_revберётся изresolved.layout_dataдо сравнения сexpected_rev— устаревший клиент после recovery получаетconflictпо свежей ревизии, не тихий merge._commit_pair— идентичен бывшему_commit_import_pairпо протоколу (intent → converge → один retry → rollback intent → converge rollback →PairCommitFailure), переименован и обобщён, поведение Import/space/deleteне меняется — только сообщение в логе.async_converge_store_pairreload-vs-guess. Оба try/except (config, layout) ловят исключение и перечитывают стор, сравнивая с точным ожидаемым payload (if config_data != expected_config: raise/if layout_data != expected_layout: raise) — решение об «успело записаться или нет» принимается по факту на диске, а не по типу исключения, буквально требование ТЗ §6.2.__init__.pysetup: resolver (async_resolve_pending_pair) вызывается до square-canvas миграции; после миграцииoptimize_revsпересчитывается свежим чтением обоих store передhass.bus.async_fire, событие не описывает устаревшую (домиграционную) ревизию.- Мутант-якоря — исполнено, не только прочитано. Все пять патчей #491 в
scripts/mutation-gate.mjsсинтаксически валидны и уникально адресуют текущий код (--check, вывод выше). Это не доказывает, что HA-harness guard краснеет (недоступно в этом окружении — см. «Чего не проверял»), но исключает класс дефектов «мутант ссылается на текст, которого уже нет».
Дефектов, ломающих AC1–AC5, AC7–AC9, чтением не найдено — согласуется с выводом r1, полученным независимо мной, а не переписанным с его документа.
Находки
High — 0
Medium в скоупе — 0
Единственная Medium-находка r1 закрыта (см. «Закрытие раунда r1»).
Medium вне скоупа — 1, заведена отдельным issue
process-gate.mjs --github-range может дать ложный красный по правилу 8,
когда параллельный push в dev обгоняет git fetch внутри CI-джоба
ревьюемой ветки. Разобрано выше в разделе «Зелёный CI на этом SHA».
Не в скоупе #491 (чужая подсистема — CI-инфраструктура, класс B в
scripts/process-gate.mjs, не custom_components/houseplan/*.py). Заведено:
#504 с метками
process, P2, S1-new, ссылкой на #491.
Low — 1
docs/TESTING.md не называет пятый мутант, добавленный фиксом r1.
- Файл:
docs/TESTING.md, пункт «An unfinished config/layout pair survives the next writer (#491)» (добавлен в исходном коммите488f70c4). - Список
mutations:перечисляет 4 идентификатора (pair-recovery-config-writer-skips-fence,pair-recovery-point-writer-skips-fence,optimize-skips-pair-retry-rollback,optimize-undo-skips-pair-retry-rollback), но не пятый —pair-recovery-fence-ignores-resolution-failure— добавленный после этого коммита, в фиксе r1→r2 (1787e505, ранее4ee83716), именно для закрытия AC6. Сама реализация и сам мутант корректны (см. «Закрытие раунда r1»); расходится только текст канонического списка доказательств вTESTING.md. - Влияние: AC10 требует, чтобы Testing «фиксировал… точные команды
доказательства» — сейчас читатель
TESTING.mdне узнает о существовании пятого, самого свежего мутанта, закрывающего именно AC6 (историю которого ТЗ и оба код-ревью подробно обсуждают). Функционально ничего не сломано: мутант существует, зарегистрирован вscripts/mutation-gate.mjs, проверен--check. - Решение ревьюера: снимается с записью, без возврата на цикл. Причина —
находка чисто текстовая (список из пяти строк, где не хватает одной),
не затрагивает ни один AC по существу, её цена правки тривиальна и не
соразмерна очередному циклу ревью при нулевых High. Если браться за неё —
одна строка в
docs/TESTING.md, добавляющая`pair-recovery-fence-ignores-resolution-failure`в списокmutations:того же пункта; отдельный issue не требуется (Low, §2.7).
Что проверено и корректно
- AC1/AC2 (Optimize/Undo — общий retry/rollback). Код идентичен
проверенному в r1 материалу; я перечитал
ws_plan_optimize/ws_plan_optimize_undoцеликом заново — оба строятpending/rollbackсfinal_metadataи идут через_commit_pair. Мутантыoptimize-skips-pair-retry-rollback/optimize-undo-skips-pair-retry-rollbackцелятся именно в замену_commit_pairна голый_persist_pair_intent + _converge_pair— точное попадание в защищаемую строку (подтверждено якорем--check). - AC3/AC4 (ordinary writers резолвят pending до CAS/точечной записи).
Проверено самостоятельно по всем пяти ordinary-путям (
config/set,layout/set,layout/update,layout/delete,geometry/repair):_resolved_write_pairвызывается до чтения revision/CAS/дельты;layout/updateприменяет дельту кresolved.layout_data, не к сырому. - AC5 (paired writers не начинают вторую пару поверх первой). Общий
_resolved_write_pairвызывается идентично во всех четырёх paired writer'ах — не дублированная логика. Мутанты AC3/AC4 покрывают тот же вход (_resolved_write_pair/fence), которым защищено и AC5; отдельного мутанта для AC5 ТЗ не требует (§13 AC5: «тесты и чтение кода общего входа») — это решение принято ещё на этапе спек-ревью, не пересматриваю. - AC6 (неразрешимый pending блокирует новый write). Закрыто в этом раунде — см. «Закрытие раунда r1».
- AC7 (setup завершает недоделанное).
__init__.py: resolver вызывается до square-canvas миграции, ревизии для события пересчитываются после миграции по свежему чтению — проверено самостоятельно, см. «Разбор по коду». - AC8 (one-deep семантика).
_discard_optimizer_snapshotпо-прежнему вызывается вconfig/set;layout/set/layout/updateпо-прежнему передаютremove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING). Логика не менялась этим диффом (тот же код, что видел r1). - AC9 (форматы и совместимость). Новых persisted-полей нет;
final_metadata/kind/clear_backup— существовавшие поля. Legacy pending безfinal_metadataобрабатывается веткойreplace_metadata=False— код идентичен материалу r1. - AC10 (документация).
ARCHITECTURE.md,CONFIG-COMPATIBILITY.md,USER-GUIDE(.ru).mdсверены мной построчно с кодом заново, расхождений нет;docs/CHANGELOG.md/.ru.md— в том же коммите488f70c4(User-Visible: yes), формулировки на пользовательском языке, без терминов реализации.TESTING.md— расхождение найдено, см. «Находки» (Low). - Трейлеры и процесс. 6 коммитов, каждый с
Issue: #491; веткаissue/491-optimize-undo-pair-recovery;process-gate.mjs --issuesзелёный на актуальномorigin/dev(см. «Как проверялось»); документов ревью на issue — 2 существующих (SPEC-REVIEW-491-r1,CODE-REVIEW-491-r1) плюс этот, лимит §10.2 п.7 не превышен.
Чего не проверял
- Исполнение HA-harness backend тестов (
test_ha_*.py, все 9 новыхtest_issue_491_*, все 5 мутантов #491 через реальныйbackend-test-guard.mjs) — недоступно в этом окружении: та же причина, что у r1,pytest-homeassistant-custom-component==0.13.357требует Python ≥3.14, доступен только 3.12.3. Проверено самостоятельно и переподтверждено сейчас, не только со слов r1. Все выводы по AC1–AC9 выше — чтение кода и сопоставление с тестами, не запуск; мутант-якоря проверены исполнением (--check), но не сам факт «гвард краснеет». - Зелёный CI на точном SHA
1787e505— существует, но красный, причина разобрана выше и не относится к #491 (заведено #504). - Golden/визуальные гейты, браузерные smoke,
check-docs,model-invariants— сознательно не прогонялись, обоснование в «Как проверялось» (нетsrc/**, нет геометрической трансформации,smoke-selectвернул пустой список). - Ручное тестирование в HA — вне цикла по PROCESS §2; не проводилось никем.
Вердикт
Ноль High, ноль Medium в скоупе — единственная находка r1 закрыта
(подтверждено исполнением --check, не только чтением). Одна Medium-находка
вне скоупа заведена отдельным issue #504 и не возвращает #491 автору. Одна
Low-находка (устаревшая строка в TESTING.md) снята решением ревьюера с записью —
не соразмерна возврату на цикл.
Полный независимый разбор (обязателен из-за ребейза на ушедший вперёд dev,
§7.2/§2.10) не нашёл новых дефектов в протоколе: девять точек входа fence,
порядок auth→fence, retry/rollback и setup recovery проверены самостоятельно
и подтверждают выводы r1. Красный Validate на этом SHA разобран и отнесён к
гонке CI-инфраструктуры, не к диффу #491.
Зелёный.
Материал раунда
- Ветка:
issue/491-optimize-undo-pair-recovery, коммит1787e505a192f400c363666b549dbcf46834d90f. - Вердикт этого документа:
green· High 0 · Medium 0 в скоупе / 1 вне скоупа (#504) · Low 1 (снята)
Материал раунда
- Ветка:
issue/491-optimize-undo-pair-recovery, коммит4ee837168efd— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
f8e79336179590bc03fa49c01388f34918e43436git log --all --format='%H %T' | grep f8e793361795 - ТЗ
docs/specs/491-optimize-undo-pair-recovery.md, блобc73fde225d4f284d917879b5c4a7a54d1ff7994egit log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md - Вердикт конвейера:
green· High 0