Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
+1 -1
View File
@@ -18,6 +18,7 @@
либо берётся, либо отвергается с названной причиной.
## блокеры
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
## высокий
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
@@ -25,7 +26,6 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
## средний
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
@@ -52,7 +52,11 @@ Form одной точки 2.2 мкс
## Связано
- [otvet-i-svyortka](otvet-i-svyortka.md) — воркер убирает влияние на ответ
приёму, но не на блокировку записи; задачи независимы.
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбирает доставки, ушедшие в
`failed` по этой причине.
- Разнесение ответа приёма и свёртки **сделано** (архив change
`2026-08-02-otvet-i-svyortka`): воркер убрал влияние на время ответа, но не на
блокировку записи — длинная транзакция слияния держит её по-прежнему. Заодно
оттуда взято главное смягчение: занятость базы больше не выводит доставку из
очереди, она остаётся `pending` и пересворачивается. Оракул окна —
`task verify:busy`.
- Пересборка (`healthlog reindex`) подбирает доставки, ушедшие в `failed` по
другим причинам.
-92
View File
@@ -1,92 +0,0 @@
# Разнести ответ приёма и свёртку доставки
**Приоритет:** высокий
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
`deep`, находка №4 триажа, severity major). **Решение принято** — ниже задача.
## Что не так сегодня
Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время
ответа равно времени свёртки.
`WriteTimeout` в Go ставится в `readRequest`**до** чтения тела и до вызова
обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит
30 секунд по умолчанию это бюджет на всё сразу: дочитать до 64 МиБ по
мобильной сети, сделать `fsync` архива, вставить доставку и свернуть.
Воспроизведено минимальной программой: сервер с `WriteTimeout=200ms`,
обработчик спит 500 мс.
```
handler: WriteHeader(200), body Write err=<nil>
client: elapsed=501ms err=EOF
```
Сервер считает, что отдал `200` — ошибки записи не видно, ответ ушёл в буфер и
сбрасывается позже. Клиент получил обрыв. Код обработчика этого не видит, а
`accessLog` честно запишет `status_code=200`: единственный сегодняшний канал
наблюдаемости в этом сценарии врёт.
Стоимость свёртки измерена **до** перехода на одну транзакцию на доставку:
| тело | объектов | свёртка |
|---|---|---|
| 80 КиБ | 1001 | 815 мс |
| 323 КиБ | 4001 | 3.07 с |
| 1302 КиБ | 16001 | 11.07 с |
Одна транзакция на доставку убрала около 0.7 мс на объект (прогон живого
архива ускорился с 64 до 52 секунд), но порядок величины остался: широкая
доставка по-прежнему измеряется секундами.
Бьёт это по **широким проходам**`Today`, `Previous 7 Days`, ручной
экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры
закрываются сами».
## Что решено
Вариант (а): **отвечать `200` сразу после архивации и учёта; свёртка —
воркером в порядке журнала, с подбором `pending` при старте.**
Почему он, а не альтернативы:
- Поднять `write_timeout` до согласованного с `foldTimeout` — дёшево, но
худший случай (64 МиБ) всё равно минуты, и молчание `accessLog` остаётся.
Это лечит симптом.
- Оставить как есть — широкие проходы продолжают рваться.
Вариант (а) решает причину и попутно снимает две смежные дыры: параллельные
доставки одной автоматизации перестают гонять наследование слоя (сейчас вторая
может не найти слоя первой и уйти в `failed`), и доставка, застрявшая в
`pending` из-за сбоя записи, наконец кем-то подбирается.
## Что делать
1. Воркер свёртки: одна горутина, очередь идентификаторов доставок, обработка
**строго в порядке журнала** (`received_at`, `id`) — от этого зависит
наследование слоя и воспроизводимость.
2. Приём отвечает `200` после архивации и вставки доставки; свёртку ставит в
очередь. Очередь переполнена — доставка остаётся `pending`, это не отказ.
3. Подбор `pending` при старте, тем же путём. Это половина `reindex`, поэтому
код должен быть общим с ним, а не соседним.
4. Остановка сервиса дожидается текущей доставки: свёртка — одна транзакция,
рвать её нечем, но очередь надо дренировать осознанно.
5. Метка «доставка ждала свёртки дольше N» — в наблюдаемость, чтобы отставание
воркера было видно до того, как оно станет отставанием на сутки.
6. Тесты: порядок журнала соблюдается при конкурентных доставках; `pending`
подбирается при старте; отмена контекста не оставляет половинчатого
состояния; `task verify:archive` даёт то же состояние.
## Что стоит без решения
Ничего: свёртка работает, просто рискует не уложиться в таймаут на самых
широких доставках. Данные при этом не теряются — тело ложится в архив **до**
свёртки.
## Связано
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбор `pending` это её половина;
делать одним кодом.
- [stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
таймаут» и отставание воркера должны попасть туда.
@@ -0,0 +1,74 @@
# Порядок журнала при конкурентных приёмах
**Приоритет:** блокеры
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
## Что решить
Метка `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` и как давно, — иначе повторы будут лечить болезнь, которую
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
+8
View File
@@ -14,5 +14,13 @@
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
когда.
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
Активное уведомление — отдельная задача, здесь только факт.