- каждая запись каталога задач получила тип вместо тега kind: и префикса заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок секций канонический - поправлены протухшие факты: нереализованные маршруты Read API, MCP и `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию диффа, периметр перестал дублировать security.md - замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек storage и parsing
150 lines
13 KiB
Markdown
150 lines
13 KiB
Markdown
# 🐞 Держать порядок журнала при конкурентных приёмах
|
|
|
|
- **Тип:** fix
|
|
- **Категория:** Ядро
|
|
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в 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` и как давно, — иначе повторы будут лечить болезнь, которую
|
|
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
|