diff --git a/docs/specs/491-optimize-undo-pair-recovery.md b/docs/specs/491-optimize-undo-pair-recovery.md new file mode 100644 index 00000000..1a496fcf --- /dev/null +++ b/docs/specs/491-optimize-undo-pair-recovery.md @@ -0,0 +1,386 @@ +# #491 — Незавершённая пара Optimize/Undo переживает следующую запись + +Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491). +Ветка: `issue/491-optimize-undo-pair-recovery`; база аналитики: +`dev@c9a0690c`. +Редакция: 2026-09-09. Полный трек, P1/bug. +Канонический статус — метка issue, не заголовок этого документа. + +## 1. Сценарий + +Администратор дома подтверждает **«Оптимизировать планы»** либо серверную +отмену этой операции. Между независимыми записями конфигурации и расположения +случается временная ошибка диска. Не перезапуская Home Assistant, пользователь +или другая открытая карточка затем сохраняет обычную настройку либо позицию. + +Это сценарий SCOPE J6: House Plan обязан сохранить согласованную модель дома и +не превратить временный сбой одной операции в скрытую потерю плана или +расположения устройств. + +## 2. Что человек увидит до и после + +**До:** следующая обычная правка может пройти поверх половины Optimize/Undo и +стереть единственный след восстановления; после перезапуска остаётся смешанная +пара из разных состояний. + +**После:** House Plan сначала прозрачно доводит незавершённую пару до записанного +целевого состояния или отката, а уже затем применяет следующую правку; если +восстановление пока невозможно, новая правка честно отклоняется и recovery +остаётся доступен для следующей попытки или перезапуска. + +## 3. Проблема и подтверждённая причина + +Config и layout хранятся в двух HA Store-файлах. `plan/optimize` и +`plan/optimize_undo` сначала кладут `optimize_pending` в metadata layout-store, +затем сохраняют config и только после этого финальный layout. Setup умеет +довести такую пару при следующем запуске. + +Текущий дефект находится в окне до restart: + +- `layout/set`, `layout/update` и `layout/delete` намеренно удаляют одновременно + `optimize_backup` и `optimize_pending`; +- `config/set` после durable config write вызывает `_discard_optimizer_snapshot`, + который удаляет те же два ключа best effort; +- сами Optimize/Undo не используют существующий retry → rollback протокол + парной записи, которым уже защищены Import и удаление пространства; +- поэтому после ошибки между половинами следующая запись способна уничтожить + intent, не завершив ни target, ни rollback. + +Backend-код этого пути не менялся между аудиторским срезом `ea6061e9` и базой +этой спецификации. Смежная #87 сохраняет metadata при geometry repair, но не +разрешает конкуренцию следующего writer с незавершённой парой. + +## 4. Область изменения + +В scope: + +1. единый crash-resumable commit-протокол для Optimize, Optimize Undo и уже + существующих парных операций; +2. write fence перед всеми командами, которые могут изменить config и/или + layout: обнаружить валидный `optimize_pending`, свести записанную пару, + перечитать оба store и только затем продолжить текущую команду; +3. поведение при fail-before-write и fail-after-durable-write каждой половины; +4. поведение следующей записи без restart и setup recovery после restart; +5. HA-harness fault-injection тесты на настоящих Store и WS handlers; +6. актуализация канонического storage-контракта и пользовательского описания + отказоустойчивости. + +Не в scope: + +- изменение геометрии, отчёта или UI Optimize; +- новый формат config/layout, новая ревизия схемы или миграция существующих + данных; +- переписывание HA Store или буквальная транзакция между двумя файлами; +- изменение one-deep семантики: успешный обычный edit после полностью + завершённого Optimize по-прежнему делает Undo недоступным; +- восстановление исторически потерянного `optimize_pending`, которого уже нет; +- изменение Import, удаления пространства или geometry repair сверх перевода + на общий безопасный примитив без смены их контракта; +- новое пользовательское окно, кнопка или настройка. + +## 5. Термины и инварианты + +- **before-pair** — согласованные config/layout и metadata до операции; +- **target-pair** — канонические config/layout и их точные целевые revisions; +- **pending intent** — durable `optimize_pending`, содержащий target либо + rollback pair и конечную metadata; +- **resolved pair** — оба store соответствуют одному pending intent, а сам + intent удалён последней layout-записью; +- **ordinary writer** — `config/set`, `layout/set`, `layout/update`, + `layout/delete` и `geometry/repair`; +- **paired writer** — `plan/optimize`, `plan/optimize_undo`, `import/apply` и + `space/delete`. + +Обязательные инварианты: + +1. pending становится durable раньше первой видимой половины; +2. pending удаляется только финальной layout-записью resolved pair; +3. ни один следующий writer не читает частичную пару как исходную; +4. событие успеха и успешный WS-ответ появляются только после обеих durable + половин и удаления pending; +5. при невозможности resolution новая операция не пишет собственный кандидат, + не создаёт собственное событие и не удаляет pending/backup; +6. после resolution текущая команда заново читает revisions и проходит обычный + CAS/no-op/validation путь — сохранённые до сбоя значения не используются. + +## 6. Контракт парного commit + +### 6.1 Подготовка + +Каждый paired writer строит два явных канонических объекта: + +- target intent: требуемые config/layout, точные target revisions и metadata, + которая должна остаться после успеха; +- rollback intent: before-pair с исходными revisions и исходной metadata. + +Optimize при успешном target оставляет новый `optimize_backup`; Undo при успехе +очищает backup и относящийся к заменённому layout `repair_backup`. Rollback +возвращает исходную metadata без потерь неизвестных ключей. + +### 6.2 Выполнение и ошибки + +Общий helper выполняет действующий безопасный порядок Import: + +1. durable target intent; +2. запись target config; +3. финальная запись target layout с конечной metadata и удалением intent; +4. при исключении — перечитать store и один раз повторить сведение того же + target, потому что HA Store может бросить исключение уже после durable write; +5. при повторной неудаче — durable заменить intent на rollback и попытаться + свести before-pair; +6. если rollback завершён, вернуть стабильную ошибку операции; +7. если rollback также не завершён, вернуть стабильную ошибку и оставить + rollback intent для write fence/setup recovery. + +Нельзя угадывать по исключению, успела ли запись на диск: решение всегда +принимается по перечитанному store и полному записанному intent. + +Для Optimize/Undo ошибка не должна выходить необработанным исключением из WS +handler. Используется существующий канал ошибки сохранения; новый UI-контракт +не вводится. + +## 7. Write fence до следующей операции + +Под `write_lock`, до чтения revisions/валидации собственного кандидата, каждый +ordinary и paired writer проверяет layout metadata. + +Если валидного pending нет, поведение не меняется. + +Если pending есть: + +1. helper сводит строго пару, записанную внутри него, включая `final_metadata`; +2. при успехе writer перечитывает config и layout и начинает обычную работу с + полученной согласованной пары; +3. CAS-команда со старой revision получает обычный `conflict`, а не молчаливый + merge; клиент перечитывает данные штатным путём; +4. point writer без CAS (`layout/update/delete`) применяет только собственную + точечную дельту поверх восстановленного layout и не теряет остальные точки; +5. при новой ошибке сведения writer возвращает стабильную ошибку сохранения, + ничего своего не записывает и сохраняет pending/backup. + +No-op проверяется после fence и повторного чтения. Поэтому реальный no-op не +создаёт revision и не инвалидирует актуальный one-deep backup; операция, +которая лишь выглядела no-op относительно устаревшей половины, оценивается уже +от resolved pair. + +Команды, только читающие stores, в этой задаче не превращаются в writers. +Кратковременная частичная картина между исходной ошибкой и write fence/restart +допустима; задача закрывает потерю recovery, а не добавляет read-repair. + +## 8. Startup recovery + +Setup использует тот же resolver pending-пары, а не отдельную расходящуюся +реализацию. Старые валидные pending без `final_metadata` продолжают +обрабатываться по текущему compatibility fallback: сохранить backup для target, +очистить его для Undo/rollback согласно сохранённым признакам. + +Recovery идемпотентен: + +- target уже записан в обе половины, но intent остался после fail-after-write — + повтор удаляет intent без нового смыслового состояния; +- записана только config-половина — дописывается layout; +- записана только durable intent — записываются обе половины; +- сохранён rollback intent — восстанавливается before-pair, а не первоначально + запрошенный и уже объявленный неуспешным target. + +События config/layout update испускаются после успешного setup resolution как +сейчас. Неуспех setup оставляет intent и не маскируется очисткой metadata. + +## 9. One-deep Undo и совместные записи + +- Успешный Optimize оставляет `can_optimize_undo=true` только для точных + итоговых revisions пары. +- Успешный Undo удаляет backup и возвращает `can_optimize_undo=false`. +- Следующий настоящий ordinary edit сначала разрешает pending, затем меняет + свою часть и инвалидирует уже завершённый backup по прежнему правилу. +- Geometry repair остаётся maintenance-операцией и переносит актуальный backup + на новую layout revision по контракту #87. +- Чужая stale CAS-запись не должна уничтожать ни target, ни before-pair: после + resolution она получает `conflict` до своего durable write. +- Точечное перемещение/удаление, начатое после ошибки, сохраняет все чужие + позиции recovered layout и меняет только названный marker id. + +## 10. Модель данных и совместимость + +Новых persisted-полей нет. Структура `optimize_pending` и +`optimize_backup` остаётся читаемой существующими версиями. `final_metadata`, +`kind` и `clear_backup`, уже используемые Import/setup, становятся единым +внутренним протоколом всех paired writers. + +Store/model version не повышается. Экспорт, импортируемый JSON и support package +не меняют формат. Неизвестные metadata layout-store сохраняются. + +Откат к старой версии безопасен для полностью resolved pair. Если откат +происходит в момент сохранённого pending, старая setup recovery должна +понимать его в пределах уже существующей структуры; новых обязательных полей +для завершения не добавлять. + +## 11. UX и i18n + +Обычный успешный сценарий визуально не меняется. Прозрачное resolution не +показывает отдельного диалога. + +При устойчивом отказе пользователь остаётся в существующем сценарии ошибки +сохранения и может повторить действие или перезапустить Home Assistant. Новых +i18n-ключей и новых frontend toast/dialog не требуется. Backend не раскрывает +путь к файлам, exception text или содержимое плана. + +## 12. Производительность, безопасность и touch + +В обычном пути добавляется одно чтение layout metadata либо проверка уже +загруженного документа под существующим `write_lock`; сетевых запросов и +фонового polling нет. Дорогая конвергенция выполняется только при наличии +pending. Бюджеты frontend bundle/render не затрагиваются. + +Права команд не меняются. Pending не позволяет обойти `expected_rev`: +проверка разрешений остаётся до write lock, а CAS выполняется после resolution +по свежим revisions. Touch/View/Kiosk не получают нового взаимодействия. + +## 13. Критерии приёмки + +**AC1 — общий безопасный commit Optimize.** Для `plan/optimize` fault injection +до и после durable записи intent, config и финального layout либо приводит к +успешному exact target, либо возвращает контролируемую ошибку с exact rollback; +частичной пары без pending не остаётся. + +Доказательство: HA-harness parameterized backend tests на реальных handlers и +Store; мутационный свидетель удаляет retry/rollback-вызов Optimize и краснеет. + +**AC2 — тот же контракт Undo.** Для `plan/optimize_undo` те же точки отказа +дают exact restored pair либо exact pre-Undo pair; успешный Undo очищает backup, +неуспешный rollback сохраняет возможность повторного recovery. + +Доказательство: HA-harness parameterized tests; мутационный свидетель убирает +парный commit у Undo и краснеет. + +**AC3 — следующая config-запись не уничтожает recovery.** После искусственного +fail-before/fail-after второй половины следующий `config/set` сначала сводит +pending. Старая revision получает `conflict`; повтор с новой revision сохраняет +свою несвязанную правку поверх resolved target. При повторном отказе recovery +собственный config-кандидат не записан, intent/backup сохранены. + +Доказательство: HA-harness endpoint test; мутация удаления fence у +`config/set` оставляет тест красным. + +**AC4 — каждый layout writer безопасен.** Та же матрица отдельно проверена для +`layout/set`, `layout/update`, `layout/delete` и `geometry/repair`: CAS writers +конфликтуют по свежей revision; point writer меняет только названную запись; +maintenance сохраняет внешний backup по #87; никто не удаляет unresolved +pending. + +Доказательство: HA-harness parameterized endpoint tests; мутация общего fence +либо возврат прямого `remove=(backup,pending)` краснит набор. + +**AC5 — paired writers не начинают вторую пару поверх первой.** `import/apply`, +`space/delete`, повторный Optimize и Undo сначала разрешают предыдущий pending, +затем заново проверяют обе revisions. Stale запрос отклоняется без собственного +intent; свежий запрос создаёт ровно одну новую пару. + +Доказательство: HA-harness parameterized tests и чтение кода общего входа. + +**AC6 — неразрешимый pending блокирует новый write.** Если Store продолжает +отказывать во время fence, каждая команда из AC3–AC5 возвращает стабильную +ошибку, не испускает событие успеха, не пишет собственный payload и оставляет +pending/backup для повторной попытки. + +Доказательство: HA-harness negative tests с before/after snapshot и event spy; +мутационный свидетель превращает отказ fence в продолжение writer и краснеет. + +**AC7 — restart завершает оставшееся.** После каждой точки отказа AC1/AC2 и +после неуспешного fence reload интеграции приводит store к exact target либо +exact rollback, очищает pending последним и повторный reload ничего не меняет. + +Доказательство: HA-harness setup/reload tests на реальном storage fixture. + +**AC8 — one-deep семантика не изменилась.** Успешный Optimize можно отменить; +no-op Save не съедает backup; geometry repair переносит его; настоящий следующий +edit инвалидирует; успешный Undo делает повторный Undo недоступным. + +Доказательство: существующий `test_plan_optimize_pair_and_one_deep_undo_survives_geometry_repair` +плюс новые recovery cases, все зелёные. + +**AC9 — форматы и смежные операции совместимы.** Существующие Import и +space-delete retry/rollback тесты зелёные; старый pending без +`final_metadata` восстанавливается; неизвестная layout metadata не теряется; +версии stores/config и wire payload не меняются. + +Доказательство: HA-harness compatibility test, существующий import fault suite, +ревью diff схем/констант. + +**AC10 — документация соответствует реализации.** Architecture, +Config Compatibility, пользовательское описание хранения/Optimize и Testing +фиксируют write fence, retry/rollback и точные команды доказательства. + +Доказательство: `node scripts/check-docs.mjs` и ревью документации. + +## 14. План автотестов + +1. Вынести переиспользуемый fault injector, различающий fail-before-write и + fail-after-write по store, фазе, revision и наличию pending. +2. На настоящем HA websocket harness параметризовать Optimize и Undo по точкам: + intent layout; target config; final target layout; rollback intent; + rollback config; final rollback layout. +3. Для каждого остаточного pending прогнать все endpoints AC3–AC5 сначала при + восстановившемся Store, затем при продолжающемся отказе. +4. Сравнивать не только revisions, но exact canonical config/layout, metadata, + число записей, события и WS result/error. +5. После каждого вида остатка выполнить reload дважды: первый сводит, второй + доказывает fixed point. +6. Добавить адресные mutations для fence и включения Optimize/Undo в общий + retry/rollback helper. Каждый защитный AC получает таблицу «чем краснеет» в + код-ревью по PROCESS §2.7. +7. Выполнить полный backend harness в Linux CI/WSL; native Windows pure subset + не объявлять доказательством endpoint-контракта. + +## 15. Риски и меры + +- **Сведение не того состояния.** Pending является единственным authority; + helper не строит target заново из текущих половин. +- **Stale writer поверх recovery.** После fence обязательны re-read и обычный + CAS; нельзя продолжить с локальными переменными до fence. +- **Цикл ошибок Store.** Одна попытка resolution на входящий writer; без + бесконечных retries и без удержания websocket до restart. +- **Потеря неизвестной metadata.** Target/rollback несут exact final metadata, + а compatibility fallback сохраняет неизвестные ключи. +- **Расхождение setup и runtime.** Один resolver используется обоими путями. +- **Ложнозелёные fault-тесты.** Проверять fail-after-durable-write и запускать + мутации, а не только подменять helper/predicate. + +## 16. Откат + +Код можно откатить одним issue-коммитом без миграции данных: persisted-формат не +меняется. Перед откатом нужно убедиться, что в store нет активного pending, либо +дать текущей версии завершить его перезапуском. Откат возвращает прежний риск +окна error → next writer и потому не является штатным способом лечения данных. + +## 17. Release-артефакты + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: исправлена возможная потеря + согласованности плана после ошибки Optimize/Undo; +- `docs/ARCHITECTURE.md`: единый pair commit/resolver и write fence; +- `docs/CONFIG-COMPATIBILITY.md`: persisted/revision/recovery контракт; +- `docs/USER-GUIDE.ru.md` и английская пара: краткое поведение при временном + отказе сохранения; +- `docs/TESTING.md`: fault matrix и ссылки на автоматические доказательства; +- golden/docs screenshots не меняются: UI не изменяется; +- performance/front-end bundle артефакты не требуются; +- exact-SHA Linux Validate с полным backend harness обязателен перед выпуском. + +## 18. Принятые технические предположения + +Эти решения не являются продуктовым выбором и могут быть изменены ревьюером без +обращения к владельцу, если AC остаются выполнены: + +1. Общий resolver разумно разместить рядом с `async_save_*_state` в backend + storage-модуле и вызывать из setup и WS handlers, чтобы не иметь двух + реализаций recovery. +2. Существующий `_commit_import_pair` следует обобщить/переименовать, а не + копировать его для Optimize и Undo. +3. Stable error может использовать существующий `commit_failed` с безопасным + сообщением; отдельный код `recovery_pending` допустим только если не требует + нового пользовательского сценария. +4. Read-only `config/get`, `layout/get`, export и support preview не выполняют + read-repair в этой задаче; согласованность защищается write fence и setup. +5. Проверка permissions остаётся до fence: неавторизованный вызов не должен + инициировать recovery как побочный эффект. + diff --git a/docs/specs/README.md b/docs/specs/README.md index de4acf7b..6546f347 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -29,6 +29,7 @@ | Issue | ТЗ | |---|---| | [#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) | | [#486](https://github.com/Matysh/houseplan-card/issues/486) Панель House Plan в боковом меню HA | [486-house-plan-panel.md](486-house-plan-panel.md) | | [#489](https://github.com/Matysh/houseplan-card/issues/489) Объявленный `data-hp`-контракт для UI и E2E | [489-data-hp-contract.md](489-data-hp-contract.md) |