Files
houseplan-card/docs/reviews/CODE-REVIEW-625-r1.md
T
2026-09-23 11:21:18 +00:00

141 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-625-r1
**Issue:** [#625](https://github.com/Matysh/houseplan-card/issues/625) — backend I/O, конкурентность и целостность конфигурации
**Стадия:** код-ревью, заход r1 (первый код-ревью раунд задачи; ТЗ прошло r1→r2 жёлтый→зелёный ранее)
**Материал:** `15bc6bcf5a69ef3423badfc61311dd0efc072030` (ветка `issue/625-backend-io-invariants`)
**Класс изменения:** A (продукт) — `custom_components/houseplan/**/*.py`, `quality_scale.yaml`; плюс B (`tests_backend/**`, `scripts/mutation-registry.mjs`) и C (`docs/**`)
**Трек:** полный (аналитика явно назвала критерии: несколько поверхностей, влияние на конкурентность, изменение контракта валидации, сложность 8/10)
## Скоуп
Диффа по `src/**` нет вовсе (0 файлов) — чисто backend-задача:
```
custom_components/houseplan/{__init__,diagnostics,http_api,import_export,plans,
store,trails,validation,virtual_lights,websocket_api}.py, quality_scale.yaml
tests_backend/{test_ha_diagnostics(new),test_ha_import_export,test_ha_upload,
test_ha_virtual_lights,test_ha_websocket,test_trail_recorder,test_validation,
test_virtual_lights}.py
scripts/mutation-registry.mjs
docs/{ARCHITECTURE,CONFIG-COMPATIBILITY,CHANGELOG,CHANGELOG.ru}.md
```
Четыре продуктовых дефекта из аудита плюс шесть low-пунктов, все явно входящие в ТЗ:
1. блокирующий file I/O `_missing_internal_attachments` на event loop под `write_lock` → вынесен в executor;
2. `write_lock` удерживается на весь `create_export` → снят snapshot под локом, экспорт строится без него;
3. нет инварианта уникальности `markers[].id` → delta-aware previous-aware валидатор плюс исправление tombstone-double в `ws_layout_update`;
4. upload пишет до квоты/проверки → ранний Content-Length preflight, финальная проверка+promotion под `upload_lock`, `validate_asset` серилизован тем же локом;
5. low-пучок: атомарная запись плана, coalesced virtual-light save с flush при unload, redaction diagnostics, DEBUG-дедуп ревизии, `quality_scale.yaml`, flush trail при unload.
## Как проверялось
Полное построчное чтение диффа по каждому из десяти изменённых модулей `custom_components/houseplan/**` плюс всех новых/изменённых тестов. Отдельно прослежена каждая из четырёх точек вызова `validate_active_marker_ids` (websocket_api.py:1692,1939,2085; import_export.py:1893) и обе точки `_snapshot_payload`/`VirtualLightController` (store.py wiring, `__init__.py` unload). Проверены анкеры всех 46 мутантов, патчащих тронутые этим диффом файлы (`node`-скрипт, см. таблицу гейтов) — ни один не разошёлся с текущим кодом.
Для трёх защитных AC лично прогнаны отрицательные пробы (guard снят → тест краснеет) на чистых юнит-тестах, которые в этой песочнице исполнимы без Home Assistant (`tests_backend/test_validation.py`, `test_virtual_lights.py`, `test_trail_recorder.py` — не подпадают под `test_ha_*.py`, поэтому `conftest.py` их не пропускает). Результаты — в таблице «чем краснеет» ниже.
HA-tier тесты (`test_ha_*.py`) в песочнице не выполнялись: здесь нет `homeassistant` и нет `.venv-backend` (по `AGENTS.md`, он появляется только в облачных агентах). Их зелёность подтверждена отдельно: Validate на точном SHA `15bc6bcf` зелёный (ссылка в системном контексте задачи, run 35851865360), плюс автор явно называет WSL-прогон полного HA harness `879 passed, 1 skipped` и `pytest tests_backend -q` Windows-подмножества `480 passed, 4 skipped` в хендоффе.
### Гейты
| Гейт | Статус | Как получен |
|---|---|---|
| `npx tsc --noEmit`, `npm test`, `npm run build`+сверка бандлов | зелёные | Validate на `15bc6bcf` (уже подтверждено конвейером на этом SHA, run 35851865360) — не перегонялись повторно, задача backend-only и эти гейты не специфичны к диффу |
| `python -m pytest tests_backend -q` (полный, включая HA-tier) | зелёный (заявлено) | тот же Validate run + хендофф автора: Windows pure `480 passed, 4 skipped`, WSL full HA harness `879 passed, 1 skipped` |
| `python -m pytest tests_backend/test_validation.py -q` | **зелёный, воспроизведено лично** | `/tmp/review-venv` (Python 3.12 + pytest + voluptuous, установлены в песочнице): `151 passed, 1 skipped` |
| `python -m pytest tests_backend/test_virtual_lights.py -q` | **зелёный, воспроизведено лично** | `4 passed` |
| `python -m pytest tests_backend/test_trail_recorder.py -q` | **зелёный, воспроизведено лично** | `38 passed` |
| `ruff check custom_components/houseplan` | зелёный | заявлено автором; независимо перепроверено (`ruff` установлен в `/tmp/review-venv`) — `All checks passed!` на всех 10 тронутых модулях |
| `ruff check tests_backend/test_validation.py` | зелёный (19 pre-existing, не связанных с диффом; новые строки — 0 находок) | перепроверено лично, см. «Найдено и корректно» |
| mypy strict (тронутые модули) | зелёный | заявлено автором, не переисполнялось — не мой инструментарий в песочнице, риск низкий (типизация backend стабильна, диффы малы) |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет» | выполнено лично — 0 файлов `src/**`, смоки не выбираются законно, не «пропущено» |
| `node scripts/check-docs.mjs` | не требуется | 0 файлов `src/**` — отпечаток скриншотов не мог устареть |
| `npm run golden:verify`, performance-профили | не требуются | нет визуальной поверхности, AC13 явно освобождает backend-only изменение |
| мутационные анкеры (46 патчей в тронутых файлах) | зелёные, проверено лично | `node --input-type=module` скрипт сверил `find`-паттерны всех мутантов, чьи патчи целятся в 10 тронутых модулей, с текущим содержимым файлов — 0 расхождений |
| `node scripts/process-gate.mjs --issues 625` | зелёный | заявлено автором в хендоффе, трейлеры и структура коммитов подтверждают то же самое независимо (см. ниже) |
### Не проверялось и почему
- Полный HA-tier прогон (`test_ha_*.py`, ~7 файлов с новыми/изменёнными тестами) — недоступен локально (нет `homeassistant`, нет `.venv-backend`). Заменено: (а) зелёный Validate на точном SHA, (б) построчное чтение каждого нового теста на логическую корректность и falsifiability, (в) три независимые отрицательные пробы на pure-эквивалентных модулях, которые тестируют структурно ту же защиту.
- `mypy --strict` — доверено заявлению автора, не переисполнено (нет установленного mypy с конфигом проекта под рукой; риск низкий, дифф в основном добавляет функции с явными аннотациями).
- Мутационный CI-прогон (`mutation-gate.yml` / Validate `mutants=true`) — не переисполнялся (дорогой прогон); вместо этого лично сверены все анкеры (см. гейты выше) и лично воспроизведены три отрицательные пробы вручную.
## Разбор по AC
| AC | Заявлено | Проверено | Чем |
|---|---|---|---|
| AC1 | `_missing_internal_attachments` в executor, не в event loop | **да** | код: `websocket_api.py:596-601` — `await hass.async_add_executor_job(_missing_internal_attachments, ...)`; тест `test_import_attachment_scan_runs_in_executor` сравнивает `threading.get_ident()` вызова с `threading.get_ident()` event-loop потока (HA-tier, не исполнено локально, но логика теста корректна: реальный, а не шаблонный, oracle) |
| AC2 | `config/get` не ждёт `export/create` | **да** | код: `write_lock` теперь охватывает только два `async_load()`+`deepcopy`, снимается до `async_add_executor_job(create_export, ...)` (`websocket_api.py:456-483`); тест `test_config_get_does_not_wait_for_slow_export_materialization` — реальный конкурентный тест с `asyncio.wait_for(timeout=1)` и `threading.Event`, падает по таймауту, если `write_lock` держится дольше (HA-tier, не исполнен локально, но конструкция теста falsifiable и логически корректна) |
| AC3 | previous-aware валидатор отклоняет новый duplicate, разрешает неизменённую legacy-группу и т.д. | **да, с оговоркой** | `validation.py:37-62` — реализация точно соответствует ТЗ (см. «Найдено и корректно»); unit-тесты `test_validation.py` покрывают все перечисленные в AC3 случаи; **но** три из четырёх точек вызова (`ws_config_set`, `ws_space_delete`, `ws_plan_optimize`) не имеют ни одного теста, вызывающего сам websocket-хендлер с дублирующимся id — см. **M1** |
| AC4 | tombstone+live не теряет drag в `ws_layout_update` | **да** | код: `deleted = not live_explicit and any(tombstone...)` (`websocket_api.py:829-832`) — корректно меняет семантику именно так, как требует ТЗ; тест расширяет `test_deleted_marker_rejects_a_stale_layout_update` кейсом `dev_both` (tombstone + live одного id), проверяет что drag проходит и **не** помечен `ignored` (HA-tier, не исполнен локально, тест реален и falsifiable) |
| AC5 | ранний Content-Length reject до multipart/temp; сохранено поведение при отсутствующем length; cleanup при abort | **в основном да** | ранний reject: `http_api.py:378-397`, тест `test_attachment_upload_rejects_impossible_content_length_before_multipart` + `..._quota_before_multipart` (оба HA-tier, оба с синтетическим `_Request` — падают, если preflight убрать). Cleanup при abort — существующий тест `test_upload_leaves_no_temporary_behind` (не новый, но применим). **Пробел**: ни новый, ни существующий тест не проверяет явно, что streaming hard cap (`if size > MAX_FILE_BYTES`) продолжает работать, когда `content_length` отсутствует/лжёт — см. **L1** |
| AC6 | не более одного одновременного `validate_asset`; точная quota-проверка перед promotion | **да** | decor: `validate_asset` теперь внутри `async with runtime.upload_lock` (`http_api.py:334-339`); тест `test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing` расширен реальным потоковым счётчиком `max_active_validations == 1` — подлинный конкурентный oracle, не синтетика. upload: `_check_and_promote` объединяет `check_quota`+`_promote` под тем же локом (`http_api.py:507-524`); тест `test_issue_625_concurrent_uploads_serialize_exact_quota_check` гоняет два реальных параллельных запроса, `max_active == 1` |
| AC7 | сбой атомарной записи плана не портит старый файл, temp удаляется | **да, лично воспроизведено** | `plans.py:29-41` (`atomic_write`); тест `test_atomic_write_keeps_destination_and_cleans_temp_when_replace_fails` — **лично прогнан**, зелёный (151 passed вместе с остальными); лично сделана отрицательная проба (см. таблицу «чем краснеет») |
| AC8 | серия toggle → 1 coalesced запись, flush при unload | **да, лично воспроизведено** | `virtual_lights.py:44-136` (`VirtualLightController`); pure-тест `test_runtime_controller_coalesces_rapid_toggles_into_one_durable_write` — **лично прогнан**, зелёный; HA-tier тест `test_unload_flushes_a_toggle_still_inside_the_debounce_window` реально перезагружает config entry и проверяет персистентность (не исполнен локально) |
| AC9 | diagnostics не содержит binding/settings | **да, лично прочитано + тест проверен построчно** | `diagnostics.py` — `TO_REDACT` расширен `binding`,`settings`; тест `test_diagnostics_redact_marker_bindings_and_all_settings` (HA-tier, не исполнен локально) — сериализует результат в JSON и грепает шесть секретов, плюс проверяет что `rev`/`layout_entries`/`rooms`-агрегаты выжили. Логика теста подлинная (текстовый grep по сериализованному payload — сильный oracle) |
| AC10 | DEBUG-дедуп, conflict не скрыт от клиента | **да** | `_debug_missing_revision_once` (`websocket_api.py:33-45`); тесты `test_issue_340_...`/`test_issue_356_...` явно проверяют, что **оба** запроса без revision получают `conflict`, а строка лога появляется **ровно один раз**, отфильтровано по имени логгера House Plan (после исправления в `15bc6bcf`, устранившего ложный шум от HA Store) |
| AC11 | trail flush при unload, нет висящих подписок/таймеров | **да, лично воспроизведено** | `trails.py:458-479` (`async_teardown`); тест `test_async_teardown_flushes_pending_debounced_state_and_closes_handles` — **лично прогнан**, зелёный; лично сделана отрицательная проба |
| AC12 | `quality_scale.yaml` не заявляет отсутствие HTTP | **да** | текст переписан, описывает support-relay |
| AC13 | typecheck/backend/build зелёные; golden/smoke/perf не требуются | **да** | см. таблицу гейтов; backend-only diff подтверждён (0 файлов `src/**`) |
## Находки
### Medium (в скоупе задачи — чинится в этой же задаче, без отдельного issue)
**M1. Три из четырёх точек вызова `validate_active_marker_ids` не имеют ни одного теста, exercising сам websocket-хендлер.**
- **Файлы:** `custom_components/houseplan/websocket_api.py:1692` (`ws_config_set`), `:1939` (`ws_space_delete`), `:2085` (`ws_plan_optimize`).
- **Что доказано:** только сама функция `validate_active_marker_ids` (чистый юнит, `test_validation.py`, воспроизведено лично) и путь `kind=full` импорта (`test_full_import_rejects_duplicate_active_marker_ids`, вызывает `create_preview` напрямую, не через websocket-хендлер). Ни `ws_config_set` (самый частый путь записи — срабатывает на каждое сохранение из редактора), ни `ws_space_delete`, ни `ws_plan_optimize`, ни путь `kind=space` импорта (`revalidate_candidate`/`prepare_apply`) не имеют теста, который бы послал реальный дублирующийся id через сам WS-хендлер и проверил `invalid_config`/`duplicate active marker id`.
- **Почему это находка, а не примечание:** AC3 и §2.7 (таблица «чем краснеет») требуют для каждого защитного AC либо мутант (недоступно — гейт дорогой, HA-tier), либо отрицательную пробу самого ревьюера (недоступно — те же тесты недостижимы без HA), либо тест, который сам по себе умеет падать на этой конкретной точке. Ни одно из трёх не выполнено для этих трёх точек: пустой третий столбец таблицы «чем краснеет» — Medium по тексту процесса, а не заметка.
- **Смягчающее:** вызов `validate_active_marker_ids(msg["config"], data.get("config"))` в `ws_config_set` — код, который сидит рядом и **синтаксически идентичен** по контракту вызовам `validate_marker_controls(msg["config"], data.get("config"))` и остальным валидаторам этой же функции (`_validate_config_cpu`), уже покрытым отдельными тестами по тому же паттерну. Риск случайной поломки именно этой строки при будущем рефакторинге невысок, но не нулевой — то же самое рассуждение относили к #423, где паттерн «рядом стоящий работающий валидатор» не спас конкретный контракт от дыры.
- **Воспроизведение (чего не хватает):** отправить `houseplan/config/set` с двумя активными маркерами одного `id` через `hass_ws_client`, ожидать `error.code == "invalid_config"`. Аналогичный по духу тест уже существует для `import/apply` (`test_full_import_rejects_duplicate_active_marker_ids`) — паттерн можно скопировать для `config/set`, `space/delete`, `plan/optimize` и `kind=space` импорта.
- **Что делать:** добавить минимум один WS-уровневый тест на `ws_config_set` (самый частый и самый рискованный путь); по возможности — на остальные три точки. Задача не блокируется целиком: правки локальны и укладываются в оставшийся бюджет ревью.
### Low (снято записью, не блокирует)
**L1.** AC5 текстуально обещает «отдельные тесты сохраняют поведение при отсутствующем length» — для streaming hard cap (`if size > MAX_FILE_BYTES` внутри цикла чтения чанков, код не тронут этим диффом) такого теста нет ни в этом диффе, ни в `origin/dev` до него. Риск низкий: сам защитный код не менялся этой задачей, это чистый предсуществующий путь; но формально буква AC5 не полностью покрыта. Снимаю без блокировки: код не тронут, регрессионный риск равен риску, существовавшему до этой задачи.
**L2.** `store.py:async_save_config_state` — `controller.async_flush()` и последующий `controller.reset()` обёрнуты в общий `try/except Exception` вместе с `async_reconcile_virtual_lights`; если сам `async_flush()` бросит (redo-попытка при сбое диска), `reset()` не выполнится и рантайм-кэш virtual-light останется непересброшенным до следующего toggle/snapshot. Последствие ограничено: следующий `async_snapshot`/`async_toggle` всё равно пересчитает состояние относительно актуального `config_rev`, просто на один переход позже подхватит новый config. Не блокирует, автор может оставить как есть — записано для истории.
## Найдено и корректно (подробности)
- **`validate_active_marker_ids` (validation.py:37-62)** — семантика delta-aware сравнения через отсортированный JSON-подпись (`sort_keys=True`) корректно реализует все пункты ТЗ: новый дубликат id всегда отклоняется (`validate_all or previous is None`), неизменённая legacy-группа проходит (сравнение `old.get(id) == signatures` по каждому дублирующемуся id), любое изменение внутри группы либо исправляет до ≤1 активного (выходит из множества `duplicates`, проходит), либо остаётся дублем с другой сигнатурой (отклоняется). `kind=full` импорт всегда `validate_all=True` — корректно соответствует «full import не переносит новый конфликт, doesn't license перенос legacy-группы».
- **`ws_layout_update` tombstone fix** — `deleted` теперь требует `not live_explicit`; `live_explicit` не завязан на `binding`, поэтому legacy-дубликат (два активных с одним id, ещё не почищенный) не ломает drag ни для одной из копий — только настоящий tombstone-без-активного-partner по-прежнему считается удалением. Корректный минимальный фикс, ничего лишнего не меняет.
- **`ws_export_create`** — deepcopy снимается **внутри** `write_lock` (оба `async_load()` последовательно под одним и тем же локом → согласованная пара), исполнитель запускается уже снаружи. `except ImportFailure`/`except Exception` по-прежнему оборачивают весь блок (включая теперь-незалоченный executor call) — обработка ошибок не потеряна.
- **Upload preflight (`http_api.py`)** — `payload_floor = max(0, declared_size - _FLUSH_AT)` корректно реализует «консервативный отказ у самой границы квоты» из «Принятых предположений» ТЗ; финальная точная проверка остаётся под `upload_lock` вместе с promotion (`_check_and_promote`), поэтому окно между preflight и записью не создаёт TOCTOU. Коммит `15bc6bcf` исправил реальный баг первой версии (сравнение по `declared_size` без вычета multipart-overhead ложно отклоняло файл на самой границе) — обнаружен собственным CI Validate до ревью, исправлен до передачи на ревью, что и требует #510.
- **`VirtualLightController`** — единственный фоновый `_save_task` на контроллер, `_schedule_save` не плодит дублей (проверяет `is None or done()`), `async_flush` корректно ждёт текущую задачу и досохраняет, если `_dirty` всё ещё true после неё. Ответы/события стали немедленными (оптимистичный revision), персистентность отложенная и гарантированно flush-ится при unload (`__init__.py:267-269`) и при config-транзишне (`store.py:236-241`, до `async_reconcile_virtual_lights`, чтобы reconcile не гонялся со старым pending-состоянием).
- **Diagnostics** — `settings` целиком редактируется через оборачивающий словарь (`async_redact_data({"settings": ...}, TO_REDACT)["settings"]`), что валидный способ заредактировать весь блок целиком через существующий helper, не изобретая новый.
- **Мутационные анкеры** — все 46 патчей, целящихся в 10 тронутых этим диффом файлов, сверены построчно с текущим содержимым: 0 расхождений. Переименование `quota-ignores-foreign-staged-uploads` → `quota-check-and-promotion-are-not-serialized` оправдано: защита, которую тестировал старый мутант (незалоченный quota-check игнорирует ЧУЖОЙ staged-файл), больше не воспроизводима в новой конструкции — quota-check и promotion теперь строго сериализованы одним локом, поэтому к моменту чужой проверки конкурента либо уже нет (он либо промотирован, либо отклонён). Новый мутант тестирует именно новый контракт (сериализация). Механизм exclude-своего-файла (`plans.py:229`, ради которого был старый мутант) не тронут этим диффом и по-прежнему защищён комментарием+существующим `dir_usage`-тестом косвенно через `test_issue_498_upload_accepts_the_last_bytes_and_the_last_file_of_the_quota`.
- **Трейлеры и changelog** — терминальный коммит `965bbb05` несёт `User-Visible: yes` и правит оба changelog в этом же коммите; два последующих fix-коммита (`4051899b`, `15bc6bcf`) — `User-Visible: no`, корректно, так как чинят баг, обнаруженный CI до выхода из ревью, не добавляя новое видимое поведение сверх уже описанного.
- **`quality_scale.yaml`** — текст корректно описывает support-relay, не заявляет более ничего лишнего.
## Итог
High: 0. Medium (в скоупе): 1 (M1). Low: 2, обе сняты записью выше, не требуют правки.
Единственная блокирующая для зелёного вердикта находка — M1: три из четырёх мест, где вводится центральный инвариант задачи, не имеют собственного witness-теста (ни мутанта, ни исполнимой ревьюером отрицательной пробы, ни HA-tier теста, вызывающего сам хендлер). Это не сомнение в правильности кода — построчное чтение показывает точное соответствие ТЗ и консистентность с соседними уже проверенными вызовами — а отсутствие доказательства по правилу §2.7. Фикс узкий (добавить 1–4 WS-теста по образцу уже существующего `test_full_import_rejects_duplicate_active_marker_ids`) и не требует нового раунда полного разбора — только доказательства по AC3.
**Вердикт: жёлтый.**
---
## Материал раунда
- SHA: `15bc6bcf5a69ef3423badfc61311dd0efc072030`
- Дерево: `git diff origin/dev...HEAD` — 26 файлов, +1341/−138
- Ветка: `issue/625-backend-io-invariants`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/625-backend-io-invariants`, коммит `15bc6bcf5a69` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `07548b0e43a44ee1943690b774235e9284f85030`
```
git log --all --format='%H %T' | grep 07548b0e43a4
```
- Тело issue: `f9d2f583a9c977bd535f6f14de24ad8c8757914db26218bea46875ad89fbaa67`
- Вердикт конвейера: `yellow` · High 0