mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
386 lines
28 KiB
Markdown
386 lines
28 KiB
Markdown
# #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 как побочный эффект.
|