Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
@@ -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` по
|
||||
другим причинам.
|
||||
|
||||
@@ -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` и как давно, — иначе повторы будут лечить болезнь, которую
|
||||
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
|
||||
@@ -14,5 +14,13 @@
|
||||
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
|
||||
когда.
|
||||
|
||||
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
|
||||
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
|
||||
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
|
||||
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
|
||||
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
|
||||
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
|
||||
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user