Files
houseplan-card/docs/specs/495-import-commit-and-route-runs-durability.md
T

150 lines
22 KiB
Markdown
Executable File
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.
# #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`, лишнего трафика нет.