Files
houseplan-card/docs/specs/491-optimize-undo-pair-recovery.md
T
2026-09-08 23:01:49 +00:00

28 KiB
Raw Blame History

#491 — Незавершённая пара Optimize/Undo переживает следующую запись

Issue: #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 как побочный эффект.