24 KiB
CODE-REVIEW-491-r1
Issue: #491 — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи».
Материал ревью: ветка issue/491-optimize-undo-pair-recovery, точный SHA 4c08b8abfca4a81240f98d654cc4766faae8ec58 (рабочая копия уже на нём).
Заход: r1. Блокирующих циклов израсходовано: 0/4.
ТЗ: docs/specs/491-optimize-undo-pair-recovery.md, ревью ТЗ зелёное (SPEC-REVIEW-491-r1, S5-ready).
Скоуп диффа
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/SPEC-REVIEW-491-r1.md | 165 +++++++++++
docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++
docs/specs/README.md | 1 +
scripts/mutation-gate.mjs | 87 ++++++
tests_backend/test_ha_import_export.py | 31 +++
tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++
16 files changed, 1461 insertions(+), 141 deletions(-)
Изменение backend-only (класс A: custom_components/houseplan/*.py; класс B:
scripts/mutation-gate.mjs, tests_backend/**; класс C: docs/**). src/**
не тронут — фронтенд-гейты (typecheck, smoke, golden, check-docs) вне
обязательного набора для этого диффа.
4 коммита на ветке, все с Issue: #491; коммит ba52a10f (User-Visible: yes)
несёт весь код и правки обоих changelog в одном коммите — трейлеры и правило
«документация в том же коммите» соблюдены.
Как проверялось
Дешёвые гейты — прогнаны сам, на 4c08b8ab
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
зелёный, без вывода |
| JS unit | npm test |
2318 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 файлах — diff не по TS) |
smoke-select |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
«Исполняемого frontend-диффа нет... браузер-smoke этим диффом не выбираются» — прогон смоков не нужен |
process-gate |
node scripts/process-gate.mjs |
«гейт пройден, предупреждений 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 voluptuous==0.15.2) |
395 passed, 4 skipped — не проверяет новый код: test_ha_*.py пропущен, conftest.py игнорирует эти файлы без homeassistant |
Не прогонялись и почему:
golden:verify,check-docs.mjs, browser-smoke — не нужны:src/**не менялся, видимый рендер не меняется,smoke-selectподтвердил пустой выбор.npm run invariants— диффа геометрии/ссылок (layout,marker.space,open_spans, записи толщины) нет; меняется только протокол персистентности уже существующих config/layout документов, не их геометрическое содержимое.python -m pytest tests_backend -qс реальным Home Assistant — недоступно в этом окружении.tests_backend/requirements.txtпинуетpytest-homeassistant-custom-component==0.13.357, который требует Python >=3.14; в песочнице стоит Python 3.12.3, и других версий на машине нет (проверено —python3.13/python3.14отсутствуют,.venv-backendне создан). Установкаpip install -r tests_backend/requirements.txtпадает на резолвере с этой причиной. Все AC1–AC9 этой задачи доказываются исключительно HA-harness тестами — этот гейт я не смог исполнить и не подтверждаю его результат экспериментально.
Зелёный CI на этом SHA
По условиям раунда — не найден. Разработчик в хендоффе ссылается на
exact-SHA CI 98c1923e и «полный HA backend harness в WSL: 728 passed» — оба
прогона сделаны до ребейза на dev (конфликт был только в
docs/specs/README.md, попытка слияния без ревью отклонена ботом,
issue-комментарий от 2026-09-08T22:06:57Z). Ни тот прогон CI, ни WSL-прогон
не относятся к точному SHA 4c08b8ab, который является материалом этого
ревью. Ребейз, по описанию владельца, тронул только строку в
docs/specs/README.md (список ТЗ, класс C) — не код; я не могу
пере-проверить это побайтово, потому что предребейзный SHA 98c1923e уже не
резолвится локально (обычное дело после ребейза, PROCESS §2.10), но
git diff origin/dev...HEAD для файлов класса A/B, который я разбирал ниже,
не содержит ничего, что выглядело бы как след мержа/конфликта.
Вывод: ни один AC этой задачи не подтверждён исполнением HA-harness ни мной, ни зелёным CI на этом точном SHA. Все AC1–AC10 ниже доказаны чтением, не исполнением — с указанием, что именно я проследил по коду и какому сценарию из существующих тестов это соответствует.
Разбор по коду (проверено чтением)
Прослежены построчно: custom_components/houseplan/store.py (новые
ResolvedStorePair, _pending_target, async_converge_store_pair,
async_resolve_pending_pair), custom_components/houseplan/websocket_api.py
(_resolved_write_pair, PairCommitFailure, _persist_pair_intent,
_converge_pair→_commit_pair, все точки вызова в ws_import_apply,
ws_layout_set/update/delete, ws_geometry_repair, ws_config_set,
ws_space_delete, ws_plan_optimize, ws_plan_optimize_undo) и
custom_components/houseplan/__init__.py (перенос recovery до миграции
square-canvas).
Ключевые находки чтения (все — «работает корректно», не дефекты):
- Разрешение конфликта write fence vs auth. Во всех восьми writer'ах
_check_write/_runtimeвызываются до_resolved_write_pair— предположение ТЗ №5 («проверка permissions остаётся до fence») выполнено (проверено наws_layout_setL710,ws_config_setL1588,ws_plan_optimizeL1963 и т.д.). - Совместимость со старым форматом pending.
async_converge_store_pairветвится поisinstance(pending.get("final_metadata"), dict). Веткаreplace_metadata=False— дословно перенесённая из старогоcustom_components/houseplan/__init__.py(был:if not replace_metadata and not pending.get("clear_backup") and "optimize_backup" in lay_stored: ...) логика сохранения backup поclear_backup; существующийtest_setup_recovers_exact_optimize_storage_roundtrip_pair(tests_backend/test_ha_import_export.py:2282, не тронут этим диффом) по-прежнему использует старый формат (clear_backup: False, безfinal_metadata) и по коду обязан пройти тот же путь — AC9 для setup подтверждён без изменений в самом тесте, только в реализации, которая теперь общая. - Идемпотентность recovery. Для нового формата (
replace_metadata=True) запись config пропускается, еслиconfig_data.get("config") == target_config and config_rev == target_config_rev; запись layout выполняется всегда (это и есть операция, которая в последний момент снимаетoptimize_pending) — соответствует §8 ТЗ («target уже записан в обе половины... повтор удаляет intent без нового смыслового состояния»). - Постоянный порядок операций в setup (
__init__.py): resolver теперь вызывается до square-canvas миграции (раньше — после), с явным комментарием о причине; после миграцииoptimize_revsпересчитывается свежим чтением обоих stores передhass.bus.async_fire, так что событие не описывает уже устаревшую (домиграционную) ревизию. _commit_pair— прямое переименование бывшего_commit_import_pair(intent → converge → retry once → rollback intent → converge rollback →PairCommitFailure), протокол не менялся, только обобщено имя и убраны специфичные для импорта строки лога; поведение для Import/space/deleteне отличается отdevкроме сообщения в логе.- Мутационный трейс AC6 вручную (пояснение ниже, в разделе находок):
прочитан код
_resolved_write_pair, при отказеasync_resolve_pending_pairфункция шлётcommit_failedи возвращаетNone; вызывающий writer делает раннийreturn. Гипотетическая мутация «проглотить исключение и продолжить со свежим (не сведённым) состоянием» превратила быtest_issue_491_failed_fence_blocks_point_write_and_keeps_intentв проходящий успешный ответ вместоcommit_failed— то есть тест по факту умеет её ловить. Это прослежено по коду, не исполнено.
Дефектов, ломающих AC1–AC5, AC7–AC10, чтением не найдено.
Находки
Medium (в скоупе, чинится в этой же задаче) — 1
AC6 не имеет названного мутанта, хотя защита живёт в продуктовом коде и проверяется исключительно дорогим HA-harness гейтом.
- Файл:
custom_components/houseplan/websocket_api.py, функция_resolved_write_pair(обработка исключенияasync_resolve_pending_pair→commit_failed, без записи и без удаления pending/backup). - Доказательство AC6 в хендоффе:
test_issue_491_failed_fence_blocks_point_write_and_keeps_intent. Третий столбец таблицы «чем краснеет» в хендоффе — «exact Store pair и pending сравниваются до/после»: это описание проверок самого теста, а не название мутации/снятой защиты/отдельной отрицательной пробы с результатом прогона. - PROCESS.md §2.7: «Мутант ... обязателен, когда защита живёт в продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не воспроизведёт отрицательный прогон второй раз... Пустой третий столбец — находка Medium, а не примечание». AC6 — ровно такая защита (backend, дорогой гейт, я сам не смог исполнить HA-harness в этом окружении и не могу подтвердить «падает» без названного мутанта — см. раздел «Чего не проверял»).
- Сценарий отказа без исправления: если протокол recovery когда-нибудь
сломают правкой рядом (например, вернут
return ResolvedStorePair(...)вместоreturn Noneв except-блоке), ни один существующий мутант вscripts/mutation-gate.mjsэтого не поймает — 4 добавленных мутанта (pair-recovery-config-writer-skips-fence,pair-recovery-point-writer-skips-fence,optimize-skips-pair-retry-rollback,optimize-undo-skips-pair-retry-rollback) целятся в отсутствие вызова fence, а не в то, что происходит, когда fence сам возвращает ошибку. - Что требуется: мутант в
scripts/mutation-gate.mjs, патчащий_resolved_write_pairтак, чтобы отказasync_resolve_pending_pairигнорировался и writer продолжал со свежим (не гарантированно сведённым) состоянием, с guard наtest_issue_491_failed_fence_blocks_point_write_and_keeps_intent(или новый более специфичный тест) и подтверждённым результатом прогона — либо эквивалентная запись «снял защиту руками, прогнал, вот вывод» в документации задачи. Без High-находок это влечёт жёлтый вердикт раунда; находка в скоупе issue #491 (сам мутационный гейт для этой задачи, п.5 «HA-harness fault-injection тесты» из раздела 4 ТЗ) и правится в этой же ветке, отдельный issue не заводится.
Low — 0
Не найдено.
Что проверено и корректно
- AC1/AC2 (Optimize/Undo — общий retry/rollback). Прочитаны
ws_plan_optimize/ws_plan_optimize_undoцеликом: обе теперь строятpending/rollbackсfinal_metadataи идут через_commit_pair, тот же примитив, что Import/space/delete. Тестыtest_issue_491_optimize_failure_restores_before_pair,test_issue_491_optimize_undo_failure_restores_pre_undo_pair,test_issue_491_optimize_fail_after_final_write_is_successмонтируют реальныйmonkeypatchнаStore.async_save(не подмену чистой функции) и сверяют точное содержимое обоих store после отказа — структурно корректный дизайн теста, ловит и «до записи», и «после durable записи» сбой. Названные мутантыoptimize-skips-pair-retry-rollback,optimize-undo-skips-pair-retry-rollbackцелятся именно в замену_commit_pairна голый_persist_pair_intent + _converge_pair— точное попадание в защищаемую строку. - AC3/AC4 (ordinary writers резолвят pending до CAS). Все шесть
ordinary-путей (
config/set,layout/set,layout/update,layout/delete,geometry/repair) вызывают_resolved_write_pairдо чтения revision/CAS;layout/update— единственный без CAS — применяет дельту кresolved.layout_data(восстановленному), не к сырому. Мутантыpair-recovery-config-writer-skips-fenceиpair-recovery-point-writer-skips-fenceцелятся именно в удаление вызова fence на этих двух путях. - AC5 (paired writers не стартуют вторую пару поверх первой).
Общий
_resolved_write_pairвызывается идентично во всех четырёх paired writer'ах (import/apply,space/delete,plan/optimize,plan/optimize_undo) — не дублированная логика, а один и тот же вызов. Дляimport/applyиspace/deleteесть прямые тесты (test_issue_491_import_apply_fences_an_older_pending_pair,test_issue_491_space_delete_fences_before_pair_revisions); дляplan/optimize/plan/optimize_undo— только чтение кода (тот же вызов на той же позиции), что соответствует принятому в ТЗ доказательству («тесты и чтение кода общего входа»). - AC7 (setup завершает недоделанное).
__init__.pyиспользует тот жеasync_resolve_pending_pair; порядок операций (resolver → geometry migration → пересчёт revs → событие) не даёт события со старой ревизией. Существующийtest_setup_recovers_durable_import_pair(не изменён, но проходит по общему коду) иtest_setup_recovers_exact_optimize_storage_roundtrip_pairзакрывают одновременно новый и legacy формат pending. - AC8 (one-deep семантика).
_discard_optimizer_snapshotпо-прежнему вызывается в концеconfig/setпосле успешной обычной правки (инвалидирует backup);layout/set/layout/updateпо-прежнему передаютremove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING). Логика инвалидации backup обычным edit'ом не менялась. - AC9 (форматы и совместимость). Persisted-схема не меняется: новых
ключей нет,
final_metadata/kind/clear_backup— уже существовавшие поля, используемые как общий протокол. Legacy pending безfinal_metadataобрабатывается веткой совместимости (см. выше). - AC10 (документация).
docs/ARCHITECTURE.md,docs/CONFIG-COMPATIBILITY.md,docs/TESTING.md,docs/USER-GUIDE(.ru).mdописывают ровно реализованный протокол (write fence до CAS/no-op, retry→rollback, порядок setup, отсутствие новых persisted-полей) — сверено построчно с кодом, расхождений нет.docs/CHANGELOG.md/docs/CHANGELOG.ru.mdв одном коммите с кодом (ba52a10f,User-Visible: yes). - Трейлеры и процесс. 4 коммита, каждый с
Issue: #491; веткаissue/491-optimize-undo-pair-recovery;process-gate.mjsзелёный локально.
Чего не проверял
- Исполнение HA-harness backend тестов (
test_ha_*.py, включая все 8 новыхtest_issue_491_*) — недоступно в этом окружении:pytest-homeassistant-custom-component==0.13.357требует Python ≥3.14, доступен только 3.12.3, других интерпретаторов на машине нет. Соответственно не проверены исполнением и 4 новых мутанта вscripts/mutation-gate.mjs(node scripts/backend-test-guard.mjs ...тоже требует HA). Все выводы по AC1–AC9 в разделе выше — чтение кода и сопоставление с существующими (не тронутыми) тестами, не запуск. - Зелёный CI на точном SHA
4c08b8ab— не существует на момент ревью (условие раунда). Прогон, на который ссылается хендофф разработчика, сделан на предребейзном SHA98c1923e, недоступном для сверки. - Golden/визуальные гейты, браузерные smoke,
check-docs,model-invariants— сознательно не прогонялись, обоснование в разделе «Как проверялось» (нетsrc/**, нет геометрии,smoke-selectвернул пустой список). - Ручное тестирование в HA — вне цикла по PROCESS §2; не проводилось никем.
Вердикт
Один Medium в скоупе задачи (отсутствие мутационного свидетеля для AC6),
High нет. По PROCESS §2.7/§4 это жёлтый вердикт: цикл возврата автору, без
отдельного issue — правка (мутант или эквивалентная запись прогона со снятой
защитой) делается в той же ветке issue/491-optimize-undo-pair-recovery.
Основная реализация (общий crash-resumable commit-протокол, write fence, setup recovery, обратная совместимость) при чтении кода выглядит корректной и хорошо согласованной с ТЗ; дешёвые гейты зелёные. Возврат вызван узким процессным требованием к доказательству одного защитного AC, а не сомнением в самом протоколе.
Материал раунда
- Ветка:
issue/491-optimize-undo-pair-recovery, коммит4c08b8abfca4— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
697d7e922c8b91078551d280f65add13c92cb79fgit log --all --format='%H %T' | grep 697d7e922c8b - ТЗ
docs/specs/491-optimize-undo-pair-recovery.md, блобc73fde225d4f284d917879b5c4a7a54d1ff7994egit log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md - Вердикт конвейера:
yellow· High 0