# #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`, лишнего трафика нет.