# Порядок журнала при конкурентных приёмах - **Секция:** ядро - **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой - **Теги:** goal:journal-and-rebuild, question **Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.** До появления наблюдаемости живём вариантом (г) с уже записанным в спеке приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает. Задача берётся после [наблюдаемости](stats-endpoint.md); ниже — исходная постановка блокера, она же ТЗ. Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль `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 — **до** записи тела в архив и до вставки строки учёта. Порядок, в котором строки становятся видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и коммитом строки проходит запись тела (измерено 184 мс на 62 МиБ) плюс ожидание занятой базы (до пяти секунд, а с повторами транзакции дольше). Путь построен и прогнан: 1. Широкая доставка **A** автоматизации X получает `received_at = T1` и уходит писать тело. 2. Узкая доставка **B** той же автоматизации (`T2 > T1`, только `sleep_analysis`, плотных метрик нет) успевает закоммитить строку первой и будит воркер. 3. Воркер видит только B, сворачивает её, наследовать слой не от кого → `ErrLayerUnknown` → `failed`. 4. `failed` фоновая свёртка не подбирает никогда. Точки B в витрину не попадут. Измерено на фикстурах: живой приём даёт `B=failed` и ноль часов `sleep_analysis/minute`; журнальный порядок — `B=parsed` и два часа. То есть живое состояние расходится с тем, что даст `healthlog reindex`, и расхождение молчит: уровень лога у этого исхода `WARN`, такой же, как у штатного «у этой автоматизации плотных метрик не бывает». **Это не регресс** — прежде свёртка шла в порядке завершения обработчиков, то есть было хуже. Изменение окно сузило и назвало предел в спеке приёма; вопрос в том, закрывать ли его совсем. ## Варианты и цена **а. Резервировать строку учёта в начале `Accept`** (до записи тела), дописывая `raw_path`/`bytes`/`sha256` после. Тогда видимость строки монотонна вместе с `received_at`. Цена: ломается инвариант «тело на диск раньше строки учёта», заведённый ровно затем, чтобы не было учтённой доставки без данных; появляется новое состояние «строка есть, тела ещё нет», которое обязаны понимать пересборка и ретеншен. **б. Откладывать свёртку доставки, пока она не «устоялась»** — не сворачивать моложе N секунд. Цена: задержка N на каждую доставку и произвольное N: окно занятости базы измерено до пяти секунд и зависит от нагрузки, так что N честно не выбрать. **в. `ErrLayerUnknown` в живом пути не выводит доставку из очереди** — ограниченное число повторов, потом `failed`. Цена: колонка счётчика попыток (миграция) и политика «сколько попыток достаточно»; зато лечит и прочие случаи «предшественница ещё не доехала». Требует правки спеки хранения («отказ разбора ⇒ `failed`»). **г. Ничего не делать**, оставив предел названным в спеке. Цена: редкая, молчаливая потеря точек у автоматизаций без плотных метрик; лечится `healthlog reindex` с остановкой сервиса и ручной подменой базы, но узнать о необходимости неоткуда — счётчика `failed` в рантайме нет. ## Что заблокировано Ничего: задача про разнесение ответа и свёртки доведена до конца в объявленных границах, предел записан в спеке приёма. Заблокировано только **закрытие** предела. Смежно: пока предел жив, полезно уметь сверять живую витрину с пересборкой — `reindex` уже печатает оба отпечатка, но по расписанию их никто не сравнивает. ## Рекомендация **(в)**, но не раньше `/stats`: сперва должно стать видно, сколько доставок числится `failed` и как давно, — иначе повторы будут лечить болезнь, которую никто не наблюдает. До тех пор — (г) с уже записанным пределом.