store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+65 -1
View File
@@ -2,7 +2,7 @@
- **Секция:** ядро
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- **Теги:** goal:journal-and-rebuild
- **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
@@ -13,6 +13,70 @@
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
## Вопросы
**Решение (в) порядок журнала не восстанавливает, а цена окна выросла.**
Записано 2026-08-04 задачей `tie-break-equal-completeness`.
Что случилось. Тай-брейк точек при равной полноте сменён на «побеждает
пришедшая»: байтовый порядок системно хранил меньшее значение и стоил
`step_count` его рода. Плата за это названа и внесена — порядок свёртки
приведён к порядку журнала: проход воркера прекращается на первой отложенной
занятостью доставке, а не перешагивает её. Это закрыло ту половину окна,
которая была во власти воркера.
Вторая половина осталась и закрывается только на приёме: `received_at`
фиксируется при выпуске ULID, строка учёта становится видимой после записи тела
(184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более ранней
меткой сворачивается позже своей преемницы.
**Что изменилось по сравнению с постановкой ниже.** Прежде цена окна была узкой:
доставка без плотных метрик не выводила слой и уходила в `failed` — класс редкий
(только автоматизации без плотных метрик). Теперь та же перестановка оставляет в
витрине значение не той доставки, что стоит в журнале последней, — **у любой
метрики**. Расхождение живой витрины с пересборкой перестало быть свойством
редкого класса и стало свойством любого столкновения равной полноты, то есть
98,8% спорных координат (находка 54).
**Почему это вопрос, а не работа.** Выбранный вариант **(в)** — повторы при
`ErrLayerUnknown` — лечит невыводимый слой, но порядок журнала не
восстанавливает: доставка всё равно сворачивается после своей преемницы, просто
не уходит в `failed`. Порядок восстанавливают только **(а)** (резервировать
строку учёта в начале `Accept`) и **(б)** (выдержка перед свёрткой). То есть
после реализации (в) заявленное равенство «пересборка = приём» останется
недостижимым, а спека хранения будет обещать его условно.
**Что сделано вместо, чтобы не молчать.** Воркер перед свёрткой спрашивает
журнал, есть ли доставка позже этой в статусе `parsed` или `partial`; есть —
пишется `WARN` с идентификатором. Расхождение стало наблюдаемым и лечится
`healthlog reindex`. Это страж окна, и его сносят вместе с окном.
**Варианты и цена — те же, что ниже, плюс четвёртый.**
- **(в), как решено** — окно живёт, наблюдается `WARN`, лечится пересборкой.
Дёшево; цена — «витрина есть свёртка журнала» держится на прогоне, который в
гейт не входит.
- **(а)** — резервировать строку учёта до записи тела. Закрывает окно совсем.
Цена: ломается инвариант «тело на диск раньше строки учёта», появляется
состояние «строка есть, тела нет», которое обязаны понимать пересборка и
ретеншен.
- **(а′)** — не резервировать, а **сериализовать** выпуск ULID вместе с записью
тела и вставкой строки: тогда видимость строк монотонна вместе с метками, а
инвариант «тело раньше строки» сохраняется. Цена: приём становится
последовательным, и батч-доставки HAE выстраиваются в очередь (184 мс на
62 МиБ на доставку).
- **Провенанс на объект** (не на точку) — колонка с позицией журнала у часового
объекта, тай-брейк по ней, как у сущностей. Правило снова становится
коммутативным, окно перестаёт быть дефектом, барьер и `WARN` не нужны. Цена:
миграция и смена формата, которую решение владельца 2026-08-04 запретило по
бюджету, — но запрет там назван бюджетным, а не принципиальным.
**Рекомендация.** Пересмотреть (в) в пользу **(а′)**: он единственный закрывает
окно, не трогая ни схему, ни инвариант «тело раньше строки». Если
последовательный приём неприемлем по задержке — тогда провенанс на объект, а не
жизнь с условным равенством: сегодня его проверяет один прогон, который гоняют
руками.
## Что происходит
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
@@ -31,6 +31,52 @@
слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из
будущего» (`docs/review.md`, 2026-08-01).
## Вопросы
Записаны 2026-08-04 по итогам ревью (профиль `deep`). Работа доведена до
коммита в объявленных границах; эти два решения не мои.
**1. Пересобирать ли живую витрину сейчас.** Новое правило меняет исход на
75 494 координатах (95% — `basal_energy_burned` слоя `raw`), но действует
только вперёд: уже сохранённые часы держат значение, выбранное прежним,
измеримо смещённым правилом, пока витрину не пересоберут. Отпечаток живого
`./data` с новым правилом **не сойдётся** — это ожидаемо и названо в дизайне.
- **(а)** `healthlog reindex` с остановкой сервиса и подменой файла базы сразу
после выкладки. Цена: простой приёма на время прогона (минута на нынешнем
архиве) плюс необратимое действие руками.
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
Цена: до пересборки Read API отдаёт по историческим часам прежние значения, а
сверка отпечатков с пересборкой не сойдётся и будет выглядеть отказом.
- **(в)** не пересобирать вовсе — витрина сойдётся только по тем координатам,
которые переприедут доставками. Цена: смещение остаётся в истории навсегда,
обнаружится сверкой с родным экспортом Apple, то есть месяцами позже.
Рекомендация: **(а)**. Подмена файла базы — необратимое действие человека
(`CLAUDE.md`), выполнить его я не вправе; этим изменением оно и не выполняется.
**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
гаснет, когда значения общих содержательных ключей разошлись, — и тогда
пришедшая точка побеждает, даже если унесёт ключ, которого сама не несёт.
Замер: 2 координаты из 80 129 спорных на живом корпусе, обе — те же, что дают
несравнимые наборы.
- **(а, сделано)** оставить правило и завести счётчик `PointsErased` с `WARN` и
координатами. Цена: событие наблюдается, но не предотвращается; обратимо
пересборкой, пока жив архив.
- **(б)** сузить «побеждает пришедшая» до случая, когда множества
содержательных ключей совпали, а при строгом включении имён оставлять более
полную независимо от происхождения. Цена: правило перестаёт быть чисто
структурным на этом разряде, дельта хранения переписывается, прогон живого
архива надо снимать заново. Проверить обязательно: сохраняется ли при этом
починка `step_count` — по замеру его столкновения идут с одинаковыми
наборами `{date, qty}`, то есть должна сохраниться.
Рекомендация: **(а)** — она уже реализована, потому что не меняет принятого
владельцем правила и восстанавливает наблюдаемость. Переход к (б) остаётся
дешёвым: счётчик скажет, если событие станет массовым.
## Критерии приёмки
- ни одна метрика не теряет род из-за столкновения равной полноты; `step_count`