From 70a394d22821233f0fff37302a772717c3e1d9bd Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 08:26:52 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20for=20#495=20=E2=80=94=20import?= =?UTF-8?q?=20result=20follows=20the=20commit,=20dropped=20route=20runs=20?= =?UTF-8?q?reach=20the=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #495 User-Visible: no --- ...import-commit-and-route-runs-durability.md | 149 ++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 150 insertions(+) create mode 100755 docs/specs/495-import-commit-and-route-runs-durability.md diff --git a/docs/specs/495-import-commit-and-route-runs-durability.md b/docs/specs/495-import-commit-and-route-runs-durability.md new file mode 100755 index 00000000..a6fb6bbe --- /dev/null +++ b/docs/specs/495-import-commit-and-route-runs-durability.md @@ -0,0 +1,149 @@ +# #495 — Результат Import согласован с commit; удаление маршрутов робота доходит до Store + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/495 +- **Тип / приоритет:** bug / P2 +- **Трек:** полный — две поверхности с независимыми контрактами durability (эндпоинт `houseplan/import/apply` и рекордер следов), критерий §5 «одна поверхность» не проходит +- **Оценка:** пользовательская ценность 6/10; ценность для разработки 5/10; сложность 3/10; риск 3/10 +- **Связано:** аудит 2026-09-08 §11 п.6 (B2/B3); #491 (фехтование pending-pair, S8); #335 (откат памяти при сбое Store); #162 (маршруты и `drop_unknown_routes`); #265 (Apply применяет ровно материализованный кандидат); `docs/SCOPE.md` J6 + +## 1. Проблема + +Два места, где ответ пользователю или память процесса расходятся с тем, что записано на диск. Оба подтверждены чтением `dev@abbca50c`. + +**B2. Import: ложный отказ после успешного commit.** `ws_import_apply` берёт кандидат `get_candidate(rt, token, owner)` при входе под `write_lock`, проверяет ревизии, материализует пару, пишет её `_commit_pair` и **после** этого вызывает `get_candidate(..., consume=True)` — ту же функцию с проверками TTL, владельца и дайджеста. Если TTL (`IMPORT_PREVIEW_TTL_S`, 10 мин) истёк за время `prepare_apply` + двух записей Store, или если параллельный `POST /api/houseplan/import/preview` того же пользователя вытеснил токен по лимиту (`create_preview` меняет `runtime.import_previews` из executor, вне `write_lock`), повторная выборка бросает `ImportFailure("preview_expired")`. Обработчик отвечает ошибкой; `houseplan_config_updated`/`houseplan_layout_updated` не уходят, `_purge_trail_recorder`/`_refresh_trail_recorder` не выполняются, `send_result` нет. План при этом уже заменён (rev +1 в обоих Store). Карточка показывает ошибку в диалоге импорта и держит старую модель до чужого события или перезагрузки. + +**B3. Маршруты робота: удаление не сохраняется.** `TrailRecorder.async_purge_orphans` вызывается после каждого commit конфига. Он чистит из памяти прогоны, чей `route_id` больше не существует у маркера (`book.drop_unknown_routes`), но делает это вне `_refresh_lock` и отбрасывает результат. Запись Store есть только в `_async_delete_many`, и только когда найдены маркеры-сироты. Удаление или перенацеливание одного маршрута у живого маркера → сирот нет → `return 0`, записи нет, события нет. Отложенная запись `_schedule_save` возникает лишь от новой точки робота; для стоящего робота её не будет, и после рестарта HA `Store.async_load` возвращает удалённые прогоны. Карточка их не рисует (`runRoute` не находит маршрут), но обещание «удалить пути» не выполнено, а при повторном создании маршрута с тем же id прогоны воскресают. + +## 1.1. Сценарий + +**Импорт.** Редактор загружает резервную копию, читает preview, отвлекается, нажимает «Применить» на девятой минуте. Медленный диск (SD-карта, NAS) или долгая материализация полного плана — commit заканчивается уже после десятой. Ответ: «Import preview expired». Человек делает preview заново и применяет ещё раз — и получает конфликт ревизий, потому что план уже заменён первым Apply; либо накатывает второй раз поверх, создавая дубли пространств при `duplicate_policy=virtual`. + +**Маршруты.** Владелец робота удаляет маршрут карты (или переносит его на другое пространство) в настройках маркера и сохраняет. Следы пропадают с плана. Робот стоит на базе до вечера. После рестарта HA — или при повторном добавлении маршрута с тем же id — старые пути снова в памяти рекордера. + +## 1.2. Что человек увидит до и после + +До: см. §1.1. После: Apply, дошедший до записи обеих половин, всегда отвечает `ok: true` с новыми ревизиями и рассылает оба события — независимо от того, что за время записи случилось с preview-токеном. Удалённый маршрут забирает свои прогоны с диска в ту же секунду, что и из памяти; рестарт их не возвращает; остальные прогоны маркера целы. + +## 2. Скоуп + +1. **Apply определяется durable-транзакцией** (§4): после успешного `_commit_pair` ни одна проверка кандидата не может превратить успех в отказ; токен снимается безусловно. +2. **Снятие прогонов по исчезнувшим маршрутам — durable** (§5): выполняется под `_refresh_lock`, записывается в Store одной транзакцией с удалением сирот либо немедленной записью без них; при сбое записи память откатывается (#335). +3. **Регрессионные тесты** (§6): HA-харнесс для Apply (TTL истёк во время commit; вытеснение во время commit), стаб-тесты рекордера (drop → Store, рестарт-модель через новый `TrailBook` из сохранённых данных, сбой записи → откат), мутанты на оба контракта. + +## 3. Не-скоуп + +- Алгоритмы импорта, материализации, калибровки маршрутов — не меняются (граница issue). +- Продление TTL preview или «продление при Apply» — не нужно: валидность решается один раз при входе. +- Защита токена от вытеснения *до* commit (пометка «applying») — не нужна: локальная копия кандидата уже в руках обработчика, а после commit токен снимается в любом случае; вытесненный до commit токен при неудачном Apply даёт `preview_expired` на повторе, что верно (кандидат мог устареть). +- #491-фехтование, Optimize/Undo — сделано, не трогается. +- Удаление прогонов без `route_id` (легаси, pre-#162) — по-прежнему не трогаются (усыновляются карточкой). +- Файлы пользователя (планы, вложения) — не затрагиваются. + +## 4. Apply: результат определяется commit + +### 4.1. Контракт + +Под `write_lock`, до записи, как сейчас: `get_candidate` (TTL, владелец, дайджест), сверка ревизий, политика дубликатов, материализация, проверка файлов. Это единственная точка, где preview может отклонить Apply. + +После `await _commit_pair(rt, pending, rollback)` — вместо `get_candidate(..., consume=True)`: + +```python +# Both halves are durable: the token is spent no matter what happened to +# the preview registry meanwhile (TTL, eviction by a newer preview). +rt.import_previews.pop(msg["token"], None) +``` + +`dict.pop` с умолчанием не бросает. В блоке `try` после commit не остаётся ни одного выражения, способного поднять `ImportFailure`; путь «commit прошёл → ответ ошибка» становится недостижимым по построению, а не по удаче. + +### 4.2. Семантика повторного Apply тем же токеном + +После успешного Apply — `preview_expired` (токен снят). После Apply, упавшего на `conflict`/`missing_*` до commit — токен остаётся (как сейчас, тест `test_import_apply_conflict_preserves_state_and_preview_token`). После `PairCommitFailure` — токен остаётся; план восстановлен, повтор возможен после нового preview либо тем же токеном, если ревизии сошлись (без изменений). + +### 4.3. Вытеснение во время Apply + +`create_preview` продолжает работать вне `write_lock` (CPU-работа до 8 МиБ, HA-правило «>50 мс не в loop»). Вытеснение применяемого токена не влияет на исход: кандидат — локальная переменная обработчика. Финальный `pop` идемпотентен. + +## 5. Маршруты: снятие прогонов доходит до Store + +### 5.1. Контракт `async_purge_orphans(config) -> int` + +Возвращает, как и раньше, число удалённых **маркеров**; снятые прогоны маршрутов в счётчик не входят (обратная совместимость с вызовами и тестами #335). + +Порядок под `_refresh_lock`: + +1. `dropped = self._drop_unknown_routes(config)` — для каждого живого маркера с записью в книге: `effective_routes(...)` → `book.drop_unknown_routes(marker_id, ids)`. Возвращает `{marker_id: {slot: run}}` снятых прогонов (копии для отката). +2. `orphan_ids = set(book.data) - live_marker_ids`. +3. Если нет ни сирот, ни `dropped` → `return 0` (ни записи, ни события — как сейчас). +4. Если есть сироты → существующий путь `_async_delete_many` (внутренняя версия без повторного захвата lock): книга, пары, подписка, `store.async_save(book.data)` — снятые прогоны уезжают той же записью. При исключении — восстановление сирот **и** `dropped`, повторное `_schedule_save` если ждала отложенная, `raise` → внешний `except` логирует и возвращает 0 (как сейчас). +5. Если сирот нет, но `dropped` не пуст → отменить отложенную запись (`_unsub_save`), `await store.async_save(book.data)`; при исключении — вернуть `dropped` в книгу, вернуть отложенную запись, если ждала, залогировать, вернуть 0. +6. После успешной записи (п.4 или п.5) — `hass.bus.async_fire("houseplan_trail_updated", {})` вне lock, один раз. + +### 5.2. Что не меняется + +`TrailBook.drop_unknown_routes` — семантика та же (только прогоны с непустым `route_id`; легаси не трогаются); возвращаемое `bool` сохраняется для `test_trails.py`. `async_delete` (одиночное удаление маркера) — без изменений. `_schedule_save` при новой точке — без изменений. + +### 5.3. Рестарт-модель в тестах + +Рекордер HA-независим в `test_trail_recorder.py` (стабы). «Рестарт» моделируется так: Store-стаб запоминает последнее `async_save(data)`; новый `TrailBook()` заполняется этими данными; в нём отсутствуют снятые прогоны и присутствуют остальные. Live-HA проверка эндпоинта/рекордера — `test_ha_websocket.py` по образцу `test_config_set_purges_tombstoned_and_absent_trails_durably` (#335): `config/set` с маркером, у которого из `map_routes` убран маршрут → `trail/get` без прогона этого маршрута → `recorder.store.async_load()` без него, остальные прогоны маркера на месте. + +## 6. Тесты и мутанты + +### 6.1. HA-харнесс, `tests_backend/test_ha_import_export.py` + +- `test_issue_495_apply_result_follows_the_commit_when_the_preview_expires_meanwhile`: обёртка над `wsapi._commit_pair` после реального commit ставит `rt.import_previews[token]["expires"] = 0`. Ожидание: `connection.error is None`, `result["ok"]`, `config_rev == layout_rev == 2`, оба события отправлены (по образцу `test_success_events_are_emitted_only_after_both_target_writes`), токена нет в `rt.import_previews`, Store с rev 2. +- `test_issue_495_apply_result_survives_eviction_by_a_newer_preview`: та же обёртка делает `create_preview` тем же `owner_id` `MAX_IMPORT_PREVIEWS_PER_USER` раз (лимит понижен monkeypatch) — применяемый токен вытеснен. Ожидание то же; новые токены живы. +- Существующие тесты Apply (conflict сохраняет токен; commit_failed откатывает) остаются зелёными без правок. + +### 6.2. Стабы рекордера, `tests_backend/test_trail_recorder.py` + +- `test_issue_495_dropped_route_runs_reach_the_store_without_orphans`: книга `{"live": {"current": {route_id: "vr_old"}, "previous": {route_id: "vr_keep"}}}`, конфиг с маркером `live` и `map_routes=[{id: "vr_keep"}]`; purge → `0`, Store записан один раз, в записи `live.previous` есть, `live.current` нет; событие одно; новый `TrailBook` из записи не содержит `vr_old`. +- `test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails`: FailingStore → `0`, прогон `vr_old` снова в памяти, событий нет; затем WorkingStore → записано без `vr_old`. +- `test_issue_495_dropped_route_runs_share_the_orphan_transaction`: сирота + снятый маршрут у живого маркера → `1`, одна запись без сироты и без `vr_old`, одно событие. +- Существующие тесты purge (#335) без правок. + +### 6.3. Мутанты (реестр `scripts/mutation-gate.mjs`, гард `backend-test-guard.mjs`) + +- `import-apply-rechecks-preview-after-commit`: `rt.import_previews.pop(msg["token"], None)` → `get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)`; свидетель — 6.1 первый тест. +- `import-apply-ignores-eviction-after-commit`: тот же патч, свидетель — 6.1 второй тест (регистрируется отдельно только если селектор различает; иначе один мутант с обоими тестами в `-k`). +- `trail-purge-drops-routes-in-memory-only`: в п.5 убрать немедленную запись (`await self.store.async_save(...)` → `pass`); свидетель — 6.2 первый тест. +- `trail-purge-keeps-dropped-runs-after-failed-write`: в откате п.5 убрать восстановление `dropped`; свидетель — 6.2 второй тест. +- `trail-purge-forgets-routes-when-orphans-exist`: `_drop_unknown_routes` вызывается после вычисления сирот, но результат не попадает в запись (например, `dropped` восстанавливается перед сохранением); свидетель — 6.2 третий тест. + +Каждый мутант проверяется отрицательным прогоном штатным раннером до перевода в S7. + +## 7. Совместимость и откат + +Формат Store следов не меняется; формат ответа Apply не меняется. Откат — revert одного коммита; данных, требующих миграции, нет. Рестарт после отката вернёт прежнее поведение без побочных эффектов. + +## 8. Критерии приёмки + +- AC1. Apply, у которого обе половины записаны, отвечает `ok: true` с новыми ревизиями и отправляет оба события, даже если TTL preview истёк во время записи (тест 6.1-1). +- AC2. То же при вытеснении токена новым preview того же пользователя во время записи (тест 6.1-2); токен после Apply снят. +- AC3. Удаление/перенацеливание маршрута у живого маркера без сирот → одна запись Store без снятых прогонов, одно событие, `return 0`; новый `TrailBook` из записи не содержит удалённых прогонов, остальные целы (тест 6.2-1). +- AC4. Сбой записи → снятые прогоны возвращаются в память, событие не уходит; следующий успешный purge записывает без них (тест 6.2-2). +- AC5. Сироты и снятые маршруты уезжают одной записью (тест 6.2-3). +- AC6. Live-HA: `config/set` с удалённым маршрутом → `trail/get` и `Store.async_load` без прогона (§5.3). +- AC7. Мутанты §6.3 пойманы штатным раннером; существующие тесты Apply и purge без правок зелёные. + +## 8.1. UX, модель данных, i18n + +Не затрагиваются. Changelog: пункт user-visible (ложная ошибка импорта; удалённые маршруты не возвращаются после рестарта). + +## 8.2. Риски и меры + +- Немедленная запись при каждом commit конфига с изменённым набором маршрутов — редкое событие (правка маркера), не горячий путь; при отсутствии изменений записи нет. +- Расширение `_refresh_lock` на `drop_unknown_routes` — чистая работа с памятью, миллисекунды; deadlock исключён (lock не захватывается повторно: удаление сирот вызывается внутренней версией без lock). + +## 9. Release-артефакты + +`docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md` — пункт в разделе следующей беты. Документация подсистем не меняется: `docs/VACUUM.md` («changing the target space is a NEW route identity … the recorded runs … are dropped») и `docs/ARCHITECTURE.md` (Apply как одна crash-resumable пара) уже описывают поведение после фикса; фикс приводит код к тексту. + +## 10. Затронутые файлы + +`custom_components/houseplan/websocket_api.py` (Apply), `custom_components/houseplan/trails.py` (`async_purge_orphans`, `_async_delete_many` → lock-обёртка + внутренняя версия, `_drop_unknown_routes`), `tests_backend/test_ha_import_export.py`, `tests_backend/test_trail_recorder.py`, `tests_backend/test_ha_websocket.py` (AC6), `scripts/mutation-gate.mjs` (мутанты §6.3), `docs/CHANGELOG*.md`, `docs/specs/README.md`. + +## 11. Принятые предположения + +- Validity кандидата решается ровно один раз — при входе под `write_lock`; истечение TTL за время записи не отменяет уже принятый Apply. Альтернатива «продлевать TTL при входе» не нужна: после commit токен всё равно снимается. +- Счётчик возврата `async_purge_orphans` остаётся числом маркеров; вызывающие его места (`websocket_api`, `__init__` recovery) значение не используют кроме логов/тестов. +- Событие `houseplan_trail_updated` при снятых маршрутах уходит один раз после записи — карточка и так перечитывает следы по `config_updated`, лишнего трафика нет. diff --git a/docs/specs/README.md b/docs/specs/README.md index 3d8f07d1..2f1e2102 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -28,6 +28,7 @@ | Issue | ТЗ | |---|---| +| [#495](https://github.com/Matysh/houseplan-card/issues/495) Результат Import согласован с commit; удаление маршрутов робота доходит до Store | [495-import-commit-and-route-runs-durability.md](495-import-commit-and-route-runs-durability.md) | | [#492](https://github.com/Matysh/houseplan-card/issues/492) Точный кандидат интеграции и полный manifest входов selection/reuse | [492-exact-candidate-and-input-manifest.md](492-exact-candidate-and-input-manifest.md) | | [#491](https://github.com/Matysh/houseplan-card/issues/491) Незавершённая пара Optimize/Undo переживает следующую запись | [491-optimize-undo-pair-recovery.md](491-optimize-undo-pair-recovery.md) | | [#490](https://github.com/Matysh/houseplan-card/issues/490) Атомарный recovery и live-состояние сводной панели | [490-summary-recovery-live-state.md](490-summary-recovery-live-state.md) |