Files
houseplan-card/legacy/specs/495-import-commit-and-route-runs-durability.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

22 KiB
Executable File
Raw Permalink Blame History

#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):

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