Приём отвечает 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
+80 -1
View File
@@ -223,9 +223,24 @@ HRV); у накопительных — только `date`. Поэтому то
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
→ разбор → запись в витрину
фоновый воркер: разбор → запись в витрину
```
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
означает «тело сохранено и учтено»; разобрано ли оно, говорит
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
заведён инвариант «дыры закрываются сами».
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
снять защиту от застрявшей записи со всех маршрутов ради одного.
Код ответа определяется **доставкой**, не разбором:
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
@@ -236,6 +251,70 @@ HRV); у накопительных — только `date`. Поэтому то
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
#### Очередь свёртки — таблица, а не структура в памяти
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
как очередь заданий»: состояние задания пишется той же базой, что и факт
события, а фоновый процесс выбирает необработанные строки.
Три следствия, ради которых так и сделано:
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
«очередь переполнена» невыразимо;
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
статус остаётся `pending`;
- **подбор `pending` при старте не является отдельным кодом** — это обычный
проход воркера, а не особый режим.
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
представление того же факта, и эти два расходятся при каждом падении; политика
переполнения всё равно требует подбора из базы, то есть того же кода — только в
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
следующей доставки, а ночью телефон молчит часами.
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
слой доставки без плотных метрик наследуется от предшествующей доставки той же
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
самого приёма.
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
(`partial`) принимается решение о судьбе тела в архиве.
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
приём ответил бы `500` по доставке, тело которой уже на диске.
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
первой — дефект одной доставки становится циклом перезапуска, при котором приём
не работает вовсе.
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
(беклог, блокеры).
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
после остановки не существует доставки, которая числится разобранной, а записана
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
(30 с) меньше бюджета свёртки (2 мин).
#### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
+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`
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
Активное уведомление — отдельная задача, здесь только факт.
+8 -1
View File
@@ -56,7 +56,14 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации).
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации),
`delivery_pending` (очередь свёртки).
`delivery_pending` **частичный** — только строки со статусом `pending`. Таблица
и есть очередь фоновой свёртки: воркер выбирает неразобранные доставки в
порядке журнала чаще, чем раз в минуту. В установившемся режиме в индексе
ноль-одна строка, тогда как полный индекс по `parse_status` хранил бы всю
историю ради выборки из одной.
## `bucket` — часовой объект точек
+2 -2
View File
@@ -12,8 +12,8 @@
## Ближайшая цель
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
беклоге ноль.
недифференцированной кучей. Приём отвечает `200`, не дожидаясь свёртки: её ведёт
фоновый воркер, для которого очередью служит сама таблица доставок.
**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`