mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
docs: specify durable Optimize and Undo recovery (#491)
Issue: #491 User-Visible: no
This commit is contained in:
committed by
claude[bot]
parent
9bfe78850d
commit
18e98ac5f2
@@ -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 как побочный эффект.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user