Files
healthlog/docs/tasks/items/journal-order-on-ingest.md
av 3d24248075 docs: документация приведена к канону av-dev-pm 4
- каждая запись каталога задач получила тип вместо тега kind: и префикса
  заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок
  секций канонический
- поправлены протухшие факты: нереализованные маршруты Read API, MCP и
  `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию
  диффа, периметр перестал дублировать security.md
- замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек
  storage и parsing
2026-08-05 19:09:35 +03:00

13 KiB
Raw Permalink Blame History

🐞 Держать порядок журнала при конкурентных приёмах

  • Тип: fix
  • Категория: Ядро
  • Зачем: Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
  • Теги: goal:journal-and-rebuild, question

Решение принято владельцем 2026-08-02: вариант (в), но не раньше /stats. До появления наблюдаемости живём вариантом (г) с уже записанным в спеке приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает. Задача берётся после наблюдаемости; ниже — исходная постановка блокера, она же ТЗ.

Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль 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, сворачивает её, наследовать слой не от кого → ErrLayerUnknownfailed.
  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 и как давно, — иначе повторы будут лечить болезнь, которую никто не наблюдает. До тех пор — (г) с уже записанным пределом.